@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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The targets step (`src/steps/targets.ts`, Task B4 — loop 2 step
|
|
3
|
+
* `targets`, the pass's LAST step): run each maintain-capable curation
|
|
4
|
+
* target's maintenance hook through `host.curation.maintain`, handing
|
|
5
|
+
* it the tick's deadline and the REMAINING daily digest runs (the
|
|
6
|
+
* budget the five model steps did not spend — the brief: "within the
|
|
7
|
+
* remaining budget").
|
|
8
|
+
*
|
|
9
|
+
* The step itself spends nothing — the target's own `run` calls are
|
|
10
|
+
* budgeted by the `runs` count it receives (one digest run each, per
|
|
11
|
+
* the P6 contract), and a target that reports `more` keeps the rest for
|
|
12
|
+
* the next pass. A failing target is logged and never blocks the others.
|
|
13
|
+
*/
|
|
14
|
+
import { stepLog } from "./run.js";
|
|
15
|
+
/** The loop-2 targets step: maintain every maintain-capable target. */
|
|
16
|
+
export async function maintainTargets(deps) {
|
|
17
|
+
const { host, user } = deps;
|
|
18
|
+
const log = stepLog(deps);
|
|
19
|
+
if (deps.budget.left() < 1)
|
|
20
|
+
return false; // nothing to hand a target: stop
|
|
21
|
+
const targets = await host.curation.targets(user);
|
|
22
|
+
for (const target of targets) {
|
|
23
|
+
if (!target.maintain)
|
|
24
|
+
continue;
|
|
25
|
+
if (deps.now().getTime() >= deps.deadlineAt)
|
|
26
|
+
return false;
|
|
27
|
+
await host.curation
|
|
28
|
+
.maintain(user, target.id, {
|
|
29
|
+
deadline: deps.deadlineAt,
|
|
30
|
+
runs: deps.budget.left(),
|
|
31
|
+
})
|
|
32
|
+
.then((report) => {
|
|
33
|
+
if (report.more) {
|
|
34
|
+
log(`loop 2: target ${target.id} stopped early — the next pass continues it`);
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.catch((error) => {
|
|
38
|
+
log(`loop 2: ${user} maintain of target ${target.id} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curator view (`src/view.ts`, tabs+skills Task 13): the per-user
|
|
3
|
+
* surface the notice and the core Skills history link navigate to —
|
|
4
|
+
* the change FEED (newest 50) with an **Undo review** action per
|
|
5
|
+
* review, and a pause switch. Skill management (list, pin,
|
|
6
|
+
* archive/restore, overlays) moved to the core Skills view when it
|
|
7
|
+
* landed (core-views/skills.ts): this view lists no learned skills and
|
|
8
|
+
* no overlays any more, and its **Skills** page action navigates
|
|
9
|
+
* `{target:{view:{extension:"core", id:"skills"}}}`.
|
|
10
|
+
*
|
|
11
|
+
* A `review` param filters the feed to that review's commits (the core
|
|
12
|
+
* skills history's curator-commit link and the notice both navigate
|
|
13
|
+
* here with it). Feed entry ids are commit SHAs in the user's curation
|
|
14
|
+
* history repo (tabs+skills Task 9), so the undo groups key on the
|
|
15
|
+
* SHA, never on the review id: a review reverted through
|
|
16
|
+
* `host.curation.undoReview` needs every commit the review authored,
|
|
17
|
+
* and grouping by review id would merge two interleaved reviews into
|
|
18
|
+
* one undo. Actors render as their names — `curator`, `agent`, the
|
|
19
|
+
* user, and `outside sigrun` for catch-up commits the store itself
|
|
20
|
+
* did not author (the git trailer's actor name).
|
|
21
|
+
*
|
|
22
|
+
* Access follows `accessOf` (spec P8: "the user, plus the superuser as
|
|
23
|
+
* with conversations"): the resource is the caller's OWN curation data,
|
|
24
|
+
* so a signed-in user is its owner ("write") and an anonymous caller
|
|
25
|
+
* has no instance at all ("none" — accessOf's ownerless rule would
|
|
26
|
+
* otherwise widen to household write, so the anonymous case is gated by
|
|
27
|
+
* hand). Every render and action resolves the CALLING principal's own
|
|
28
|
+
* stores — bob's instance reads bob's data, never alice's — and the
|
|
29
|
+
* per-principal `instanceKey` keeps two users' views apart as surface
|
|
30
|
+
* instances.
|
|
31
|
+
*
|
|
32
|
+
* The actions (what an ambient record can never carry — a view's action
|
|
33
|
+
* routes DO reach the extension). Per-item actions ride the block
|
|
34
|
+
* set's item-scoped dispatches, which carry `{itemId}`:
|
|
35
|
+
*
|
|
36
|
+
* - `undoReview` — the feed collection's `onActivate`
|
|
37
|
+
* (`{itemId: groupId}` where the group id is `review-<newest sha>`):
|
|
38
|
+
* resolves the sha back to the entry's review id through the feed
|
|
39
|
+
* (core's `undoReview` matches `entry.reviewId`, never the group
|
|
40
|
+
* key), then `host.curation.undoReview`, then `host.clearAmbient` on
|
|
41
|
+
* the notice conversation's `curator.notice` slot (the notice ledger
|
|
42
|
+
* in `src/notices.ts` remembers it); a commit with no review id
|
|
43
|
+
* answers "Nothing to undo" without calling the host;
|
|
44
|
+
* - `setPaused` — the page header's pause button (a submit dispatching
|
|
45
|
+
* `{}`): toggles the user's `paused` setting, read live by both
|
|
46
|
+
* loops — pausing takes effect on the next event, restart-free.
|
|
47
|
+
*
|
|
48
|
+
* The tree is data-driven where the block set binds (item templates
|
|
49
|
+
* over literal rows); the whole tree passes the server's own
|
|
50
|
+
* `validateTree` (pinned in the tests) because the server runs that
|
|
51
|
+
* exact walk before serving.
|
|
52
|
+
*/
|
|
53
|
+
import type { CurationFeedEntry, Principal, SigrunHost, SurfaceDef } from "@sigrun/sigrun/extension";
|
|
54
|
+
import type { Access } from "@sigrun/shared/access";
|
|
55
|
+
/** How many feed entries the view lists (the brief: newest 50). */
|
|
56
|
+
export declare const VIEW_FEED_LIMIT = 50;
|
|
57
|
+
/** How many pages a `review` deep-link walks before giving up (the
|
|
58
|
+
* link arrives from the core skills history at ANY depth; core's
|
|
59
|
+
* own `feedOf` walks until the entries run out — this view bounds
|
|
60
|
+
* the walk and says so when the bound elapses). */
|
|
61
|
+
export declare const VIEW_FEED_MAX_PAGES = 3;
|
|
62
|
+
/** One grouped review block in the feed (newest first). */
|
|
63
|
+
export interface ReviewGroup {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly at: string;
|
|
66
|
+
readonly entries: readonly {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly skill: string;
|
|
69
|
+
readonly action: string;
|
|
70
|
+
readonly at: string;
|
|
71
|
+
readonly actor: string;
|
|
72
|
+
readonly reason?: string;
|
|
73
|
+
}[];
|
|
74
|
+
}
|
|
75
|
+
/** The view's data document (everything the tree binds against). */
|
|
76
|
+
export interface CuratorViewData {
|
|
77
|
+
readonly user: string;
|
|
78
|
+
readonly paused: boolean;
|
|
79
|
+
readonly reviews: readonly ReviewGroup[];
|
|
80
|
+
/** True when a `review` deep-link missed after the bounded walk
|
|
81
|
+
* (the empty state is then "older than the recent changes", never
|
|
82
|
+
* the false "Nothing learned yet"). */
|
|
83
|
+
readonly reviewMissed: boolean;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve one principal's access to their per-user view instance (spec
|
|
87
|
+
* P8: "the user, plus the superuser as with conversations"): the
|
|
88
|
+
* resource is the principal's OWN curation data, so a signed-in user is
|
|
89
|
+
* its owner (write) and the service principal the superuser (write);
|
|
90
|
+
* an anonymous caller has no instance at all — accessOf's ownerless
|
|
91
|
+
* rule would widen to household write, so anonymous is none BY HAND.
|
|
92
|
+
*/
|
|
93
|
+
export declare function viewAccess(principal: Principal, user: string): Access;
|
|
94
|
+
/** One actor's display name (the git trailer's name: outside
|
|
95
|
+
* catch-up commits render as "outside sigrun"). */
|
|
96
|
+
export declare function actorLabel(entry: CurationFeedEntry): string;
|
|
97
|
+
/** Group a feed for undo: entries SHARING A REVIEW ID AND ADJACENT in
|
|
98
|
+
* the newest-first feed form one group — the group's id is its newest
|
|
99
|
+
* commit sha (`review-<sha>`). Keying on the sha (never the review
|
|
100
|
+
* id) keeps two interleaved reviews apart: undoing one must revert
|
|
101
|
+
* exactly the commits it authored. An entry with no reviewId groups
|
|
102
|
+
* alone (a lone commit is never undoable as a review). */
|
|
103
|
+
export declare function groupFeed(feed: readonly CurationFeedEntry[]): readonly ReviewGroup[];
|
|
104
|
+
/** The view surface (`SurfaceDef<"view">`), built over the host. */
|
|
105
|
+
export declare function createCuratorView(host: SigrunHost): SurfaceDef<"view">;
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curator view (`src/view.ts`, tabs+skills Task 13): the per-user
|
|
3
|
+
* surface the notice and the core Skills history link navigate to —
|
|
4
|
+
* the change FEED (newest 50) with an **Undo review** action per
|
|
5
|
+
* review, and a pause switch. Skill management (list, pin,
|
|
6
|
+
* archive/restore, overlays) moved to the core Skills view when it
|
|
7
|
+
* landed (core-views/skills.ts): this view lists no learned skills and
|
|
8
|
+
* no overlays any more, and its **Skills** page action navigates
|
|
9
|
+
* `{target:{view:{extension:"core", id:"skills"}}}`.
|
|
10
|
+
*
|
|
11
|
+
* A `review` param filters the feed to that review's commits (the core
|
|
12
|
+
* skills history's curator-commit link and the notice both navigate
|
|
13
|
+
* here with it). Feed entry ids are commit SHAs in the user's curation
|
|
14
|
+
* history repo (tabs+skills Task 9), so the undo groups key on the
|
|
15
|
+
* SHA, never on the review id: a review reverted through
|
|
16
|
+
* `host.curation.undoReview` needs every commit the review authored,
|
|
17
|
+
* and grouping by review id would merge two interleaved reviews into
|
|
18
|
+
* one undo. Actors render as their names — `curator`, `agent`, the
|
|
19
|
+
* user, and `outside sigrun` for catch-up commits the store itself
|
|
20
|
+
* did not author (the git trailer's actor name).
|
|
21
|
+
*
|
|
22
|
+
* Access follows `accessOf` (spec P8: "the user, plus the superuser as
|
|
23
|
+
* with conversations"): the resource is the caller's OWN curation data,
|
|
24
|
+
* so a signed-in user is its owner ("write") and an anonymous caller
|
|
25
|
+
* has no instance at all ("none" — accessOf's ownerless rule would
|
|
26
|
+
* otherwise widen to household write, so the anonymous case is gated by
|
|
27
|
+
* hand). Every render and action resolves the CALLING principal's own
|
|
28
|
+
* stores — bob's instance reads bob's data, never alice's — and the
|
|
29
|
+
* per-principal `instanceKey` keeps two users' views apart as surface
|
|
30
|
+
* instances.
|
|
31
|
+
*
|
|
32
|
+
* The actions (what an ambient record can never carry — a view's action
|
|
33
|
+
* routes DO reach the extension). Per-item actions ride the block
|
|
34
|
+
* set's item-scoped dispatches, which carry `{itemId}`:
|
|
35
|
+
*
|
|
36
|
+
* - `undoReview` — the feed collection's `onActivate`
|
|
37
|
+
* (`{itemId: groupId}` where the group id is `review-<newest sha>`):
|
|
38
|
+
* resolves the sha back to the entry's review id through the feed
|
|
39
|
+
* (core's `undoReview` matches `entry.reviewId`, never the group
|
|
40
|
+
* key), then `host.curation.undoReview`, then `host.clearAmbient` on
|
|
41
|
+
* the notice conversation's `curator.notice` slot (the notice ledger
|
|
42
|
+
* in `src/notices.ts` remembers it); a commit with no review id
|
|
43
|
+
* answers "Nothing to undo" without calling the host;
|
|
44
|
+
* - `setPaused` — the page header's pause button (a submit dispatching
|
|
45
|
+
* `{}`): toggles the user's `paused` setting, read live by both
|
|
46
|
+
* loops — pausing takes effect on the next event, restart-free.
|
|
47
|
+
*
|
|
48
|
+
* The tree is data-driven where the block set binds (item templates
|
|
49
|
+
* over literal rows); the whole tree passes the server's own
|
|
50
|
+
* `validateTree` (pinned in the tests) because the server runs that
|
|
51
|
+
* exact walk before serving.
|
|
52
|
+
*/
|
|
53
|
+
import { accessOf } from "@sigrun/shared/access";
|
|
54
|
+
import { readNotices } from "./notices.js";
|
|
55
|
+
import { CURATOR_NOTICE_KIND } from "./notices.js";
|
|
56
|
+
import { parseCuratorSettings } from "./settings.js";
|
|
57
|
+
/** How many feed entries the view lists (the brief: newest 50). */
|
|
58
|
+
export const VIEW_FEED_LIMIT = 50;
|
|
59
|
+
/** How many pages a `review` deep-link walks before giving up (the
|
|
60
|
+
* link arrives from the core skills history at ANY depth; core's
|
|
61
|
+
* own `feedOf` walks until the entries run out — this view bounds
|
|
62
|
+
* the walk and says so when the bound elapses). */
|
|
63
|
+
export const VIEW_FEED_MAX_PAGES = 3;
|
|
64
|
+
/** The zero-signal context the ambient clear runs under. */
|
|
65
|
+
const CONTEXT = { abortSignal: undefined };
|
|
66
|
+
/**
|
|
67
|
+
* Resolve one principal's access to their per-user view instance (spec
|
|
68
|
+
* P8: "the user, plus the superuser as with conversations"): the
|
|
69
|
+
* resource is the principal's OWN curation data, so a signed-in user is
|
|
70
|
+
* its owner (write) and the service principal the superuser (write);
|
|
71
|
+
* an anonymous caller has no instance at all — accessOf's ownerless
|
|
72
|
+
* rule would widen to household write, so anonymous is none BY HAND.
|
|
73
|
+
*/
|
|
74
|
+
export function viewAccess(principal, user) {
|
|
75
|
+
if (principal.user === undefined)
|
|
76
|
+
return "none";
|
|
77
|
+
return accessOf({ user: principal.user, superuser: principal.service }, { owner: user });
|
|
78
|
+
}
|
|
79
|
+
/** One actor's display name (the git trailer's name: outside
|
|
80
|
+
* catch-up commits render as "outside sigrun"). */
|
|
81
|
+
export function actorLabel(entry) {
|
|
82
|
+
return entry.actor === "outside" ? "outside sigrun" : entry.actor;
|
|
83
|
+
}
|
|
84
|
+
/** Group a feed for undo: entries SHARING A REVIEW ID AND ADJACENT in
|
|
85
|
+
* the newest-first feed form one group — the group's id is its newest
|
|
86
|
+
* commit sha (`review-<sha>`). Keying on the sha (never the review
|
|
87
|
+
* id) keeps two interleaved reviews apart: undoing one must revert
|
|
88
|
+
* exactly the commits it authored. An entry with no reviewId groups
|
|
89
|
+
* alone (a lone commit is never undoable as a review). */
|
|
90
|
+
export function groupFeed(feed) {
|
|
91
|
+
const groups = [];
|
|
92
|
+
let current;
|
|
93
|
+
let currentReview;
|
|
94
|
+
for (const entry of feed) {
|
|
95
|
+
const row = {
|
|
96
|
+
id: entry.id,
|
|
97
|
+
skill: entry.skill,
|
|
98
|
+
action: entry.action,
|
|
99
|
+
at: entry.at,
|
|
100
|
+
actor: actorLabel(entry),
|
|
101
|
+
...(entry.reason === undefined ? {} : { reason: entry.reason }),
|
|
102
|
+
};
|
|
103
|
+
if (entry.reviewId !== undefined &&
|
|
104
|
+
current !== undefined &&
|
|
105
|
+
currentReview === entry.reviewId) {
|
|
106
|
+
current = { ...current, entries: [...current.entries, row] };
|
|
107
|
+
groups[groups.length - 1] = current;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
current = {
|
|
111
|
+
id: `review-${entry.id}`,
|
|
112
|
+
at: entry.at,
|
|
113
|
+
entries: [row],
|
|
114
|
+
};
|
|
115
|
+
groups.push(current);
|
|
116
|
+
}
|
|
117
|
+
currentReview = entry.reviewId;
|
|
118
|
+
}
|
|
119
|
+
return groups;
|
|
120
|
+
}
|
|
121
|
+
/** The `review` param a render was anchored at (the core skills
|
|
122
|
+
* history link and the notice navigate with it), or undefined. */
|
|
123
|
+
function reviewParam(ctx) {
|
|
124
|
+
const review = ctx.params.review;
|
|
125
|
+
return typeof review === "string" && review !== "" ? review : undefined;
|
|
126
|
+
}
|
|
127
|
+
/** Read the feed: the plain view is the newest VIEW_FEED_LIMIT only
|
|
128
|
+
* (the section's "newest 50" contract — every rendered group's sha
|
|
129
|
+
* resolves for undo from that same page). A `review` deep-link pages
|
|
130
|
+
* on (last sha as the cursor, core's feedOf shape, bounded) until
|
|
131
|
+
* the review's entries appear or the walk elapses; a miss on ANY
|
|
132
|
+
* exit path — short page or bound — is reported, never rendered as
|
|
133
|
+
* the false "Nothing learned yet". */
|
|
134
|
+
async function feedFor(host, user, review) {
|
|
135
|
+
const store = host.curation.skills(user);
|
|
136
|
+
const collected = [];
|
|
137
|
+
let before;
|
|
138
|
+
do {
|
|
139
|
+
const rows = await store.feed(VIEW_FEED_LIMIT, before);
|
|
140
|
+
collected.push(...rows);
|
|
141
|
+
if (rows.length < VIEW_FEED_LIMIT)
|
|
142
|
+
break; // the last page
|
|
143
|
+
if (review !== undefined &&
|
|
144
|
+
rows.some((entry) => entry.reviewId === review))
|
|
145
|
+
break; // the deep-link found its review
|
|
146
|
+
before = rows[rows.length - 1]?.id;
|
|
147
|
+
} while (review !== undefined && collected.length < VIEW_FEED_LIMIT * VIEW_FEED_MAX_PAGES);
|
|
148
|
+
return {
|
|
149
|
+
feed: collected,
|
|
150
|
+
reviewMissed: review !== undefined &&
|
|
151
|
+
!collected.some((entry) => entry.reviewId === review),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Build the view's data document for one user (the render's model). */
|
|
155
|
+
async function readData(host, user, review) {
|
|
156
|
+
const settings = parseCuratorSettings(await host.stores.user(user).settings.get());
|
|
157
|
+
const { feed, reviewMissed } = await feedFor(host, user, review);
|
|
158
|
+
return {
|
|
159
|
+
user,
|
|
160
|
+
paused: settings.paused,
|
|
161
|
+
reviews: groupFeed(review === undefined
|
|
162
|
+
? feed
|
|
163
|
+
: feed.filter((entry) => entry.reviewId === review)),
|
|
164
|
+
reviewMissed,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Whether `payload` (an action's JSON argument) is a plain object. */
|
|
168
|
+
function isObject(payload) {
|
|
169
|
+
return typeof payload === "object" && payload !== null && !Array.isArray(payload);
|
|
170
|
+
}
|
|
171
|
+
/** The `{itemId}` dispatch an `onActivate` click carries. */
|
|
172
|
+
function itemIdOf(payload) {
|
|
173
|
+
if (!isObject(payload))
|
|
174
|
+
return undefined;
|
|
175
|
+
const itemId = payload.itemId;
|
|
176
|
+
return typeof itemId === "string" && itemId !== "" ? itemId : undefined;
|
|
177
|
+
}
|
|
178
|
+
/** The view surface (`SurfaceDef<"view">`), built over the host. */
|
|
179
|
+
export function createCuratorView(host) {
|
|
180
|
+
/** Reject for a caller with no instance of their own. */
|
|
181
|
+
const noAccess = () => ({
|
|
182
|
+
ok: false,
|
|
183
|
+
error: { message: "sign in to use the curator view" },
|
|
184
|
+
});
|
|
185
|
+
/** The caller's user, or a reject when they have no instance. */
|
|
186
|
+
function callerOf(ctx) {
|
|
187
|
+
const user = ctx.principal.user;
|
|
188
|
+
if (user === undefined)
|
|
189
|
+
return { reject: noAccess() };
|
|
190
|
+
if (viewAccess(ctx.principal, user) === "none")
|
|
191
|
+
return { reject: noAccess() };
|
|
192
|
+
return { user };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
id: "curator",
|
|
196
|
+
title: "Curator",
|
|
197
|
+
icon: "book",
|
|
198
|
+
placement: "view",
|
|
199
|
+
// A principal-owned surface (§7.3): the principal folds into the
|
|
200
|
+
// instance identity so two users' views are two instances.
|
|
201
|
+
instanceKey: (params, principal) => JSON.stringify([principal.user ?? "", params]),
|
|
202
|
+
render: async (ctx) => {
|
|
203
|
+
const who = callerOf(ctx);
|
|
204
|
+
if ("reject" in who) {
|
|
205
|
+
return { tree: emptyTree(), data: {}, access: "none" };
|
|
206
|
+
}
|
|
207
|
+
const data = await readData(host, who.user, reviewParam(ctx));
|
|
208
|
+
return {
|
|
209
|
+
tree: viewTree(data),
|
|
210
|
+
// SAFETY: the data document is a JSON mirror (every field a
|
|
211
|
+
// literal); the interface lacks the index signature JsonObject
|
|
212
|
+
// requires, so the cast only restores that erased identity.
|
|
213
|
+
data: data,
|
|
214
|
+
access: "write",
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
data: async (ctx) => {
|
|
218
|
+
const who = callerOf(ctx);
|
|
219
|
+
if ("reject" in who)
|
|
220
|
+
return { data: {}, access: "none" };
|
|
221
|
+
return {
|
|
222
|
+
// SAFETY: same JSON-mirror cast as `render`'s (the comment there).
|
|
223
|
+
data: (await readData(host, who.user, reviewParam(ctx))),
|
|
224
|
+
access: "write",
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
actions: {
|
|
228
|
+
undoReview: async (ctx, payload) => {
|
|
229
|
+
const who = callerOf(ctx);
|
|
230
|
+
if ("reject" in who)
|
|
231
|
+
return who.reject;
|
|
232
|
+
const itemId = itemIdOf(payload);
|
|
233
|
+
if (itemId === undefined) {
|
|
234
|
+
return { ok: false, error: { message: "undoReview expects {itemId}" } };
|
|
235
|
+
}
|
|
236
|
+
// A failing undo (the store's report, or a host error) is a
|
|
237
|
+
// named action error — never a thrown action the surface
|
|
238
|
+
// layer renders generically (main's 774c660 semantics,
|
|
239
|
+
// kept through the git-history rework).
|
|
240
|
+
const fail = (cause) => ({
|
|
241
|
+
ok: false,
|
|
242
|
+
error: { message: cause instanceof Error ? cause.message : String(cause) },
|
|
243
|
+
});
|
|
244
|
+
// On a deep-link the param already names the review — and
|
|
245
|
+
// the group's sha may sit past page 1 of the feed, where
|
|
246
|
+
// the single-page lookup below cannot see it. Dispatch the
|
|
247
|
+
// param's review id directly.
|
|
248
|
+
const anchored = reviewParam(ctx);
|
|
249
|
+
if (anchored !== undefined) {
|
|
250
|
+
let reverted;
|
|
251
|
+
try {
|
|
252
|
+
reverted = await host.curation.undoReview(who.user, anchored);
|
|
253
|
+
}
|
|
254
|
+
catch (cause) {
|
|
255
|
+
return fail(cause);
|
|
256
|
+
}
|
|
257
|
+
const anchoredNotice = (await readNotices(host, who.user)).find((entry) => entry.reviewId === anchored);
|
|
258
|
+
if (anchoredNotice !== undefined) {
|
|
259
|
+
await host.clearAmbient(anchoredNotice.conversationId, CURATOR_NOTICE_KIND, CONTEXT).catch(() => undefined);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
ok: true,
|
|
263
|
+
toast: reverted > 0 ? `Undid ${reverted} change(s)` : "Nothing to undo",
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// The dispatch carries the GROUP key (`review-<sha>`, minted
|
|
267
|
+
// by groupFeed); core's undoReview matches entry.reviewId.
|
|
268
|
+
// Resolve the sha back to the review id through the feed — a
|
|
269
|
+
// verbatim dispatch would match nothing and silently revert 0.
|
|
270
|
+
// A bare review id (the pre-upgrade coordinate, and digest
|
|
271
|
+
// ids like "digest-<date>" that already carry the prefix) is
|
|
272
|
+
// the fallback when no entry's sha matches.
|
|
273
|
+
const sha = itemId.startsWith("review-")
|
|
274
|
+
? itemId.slice("review-".length)
|
|
275
|
+
: itemId;
|
|
276
|
+
const feed = await host.curation.skills(who.user).feed(VIEW_FEED_LIMIT);
|
|
277
|
+
const reviewId = feed.find((entry) => entry.id === sha)?.reviewId ??
|
|
278
|
+
(feed.some((entry) => entry.reviewId === itemId)
|
|
279
|
+
? itemId
|
|
280
|
+
: undefined);
|
|
281
|
+
if (reviewId === undefined) {
|
|
282
|
+
return { ok: true, toast: "Nothing to undo" };
|
|
283
|
+
}
|
|
284
|
+
let reverted;
|
|
285
|
+
try {
|
|
286
|
+
reverted = await host.curation.undoReview(who.user, reviewId);
|
|
287
|
+
}
|
|
288
|
+
catch (cause) {
|
|
289
|
+
return fail(cause);
|
|
290
|
+
}
|
|
291
|
+
// Clear the notice this review set, when the ledger remembers
|
|
292
|
+
// one (a digest review with no notice clears nothing).
|
|
293
|
+
const notice = (await readNotices(host, who.user)).find((entry) => entry.reviewId === reviewId);
|
|
294
|
+
if (notice !== undefined) {
|
|
295
|
+
// The revert already succeeded — a failing clear only leaves a
|
|
296
|
+
// stale notice (replaced by the next review's), so it must
|
|
297
|
+
// never turn the undo into a failed action with no toast.
|
|
298
|
+
await host.clearAmbient(notice.conversationId, CURATOR_NOTICE_KIND, CONTEXT).catch(() => undefined);
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
toast: reverted > 0 ? `Undid ${reverted} change(s)` : "Nothing to undo",
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
setPaused: async (ctx, payload) => {
|
|
306
|
+
const who = callerOf(ctx);
|
|
307
|
+
if ("reject" in who)
|
|
308
|
+
return who.reject;
|
|
309
|
+
// The header button dispatches `{}` (toggle); a form-shaped
|
|
310
|
+
// `{values: {paused}}` or bare `{paused}` sets explicitly.
|
|
311
|
+
const explicit = (isObject(payload) && isObject(payload.values)
|
|
312
|
+
? payload.values.paused
|
|
313
|
+
: undefined) ??
|
|
314
|
+
(isObject(payload) ? payload.paused : undefined);
|
|
315
|
+
const raw = await host.stores.user(who.user).settings.get();
|
|
316
|
+
const current = parseCuratorSettings(raw).paused;
|
|
317
|
+
const paused = typeof explicit === "boolean" ? explicit : !current;
|
|
318
|
+
if (paused === current) {
|
|
319
|
+
return { ok: true, toast: paused ? "Curation paused" : "Curation resumed" };
|
|
320
|
+
}
|
|
321
|
+
await host.stores.user(who.user).settings.set({ ...raw, paused });
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
toast: paused ? "Curation paused" : "Curation resumed",
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** The signed-out/none-access tree (an empty page; the route 404s it). */
|
|
331
|
+
function emptyTree() {
|
|
332
|
+
return {
|
|
333
|
+
v: 1,
|
|
334
|
+
root: {
|
|
335
|
+
type: "page",
|
|
336
|
+
title: "Curator",
|
|
337
|
+
main: [
|
|
338
|
+
{ type: "markdown", text: "Sign in to see your curator activity." },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** The view tree: header actions (pause switch + the Skills
|
|
344
|
+
* link to the core view), then the feed grouped for undo. */
|
|
345
|
+
function viewTree(data) {
|
|
346
|
+
return {
|
|
347
|
+
v: 1,
|
|
348
|
+
root: {
|
|
349
|
+
type: "page",
|
|
350
|
+
id: "curator-root",
|
|
351
|
+
title: "Curator",
|
|
352
|
+
subtitle: `Learned activity for ${data.user}${data.paused ? " (paused)" : ""}`,
|
|
353
|
+
actions: [
|
|
354
|
+
{
|
|
355
|
+
id: "pause-switch",
|
|
356
|
+
label: data.paused ? "Resume curation" : "Pause curation",
|
|
357
|
+
icon: data.paused ? "sun" : "moon",
|
|
358
|
+
action: { type: "submit", name: "setPaused" },
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
id: "open-skills",
|
|
362
|
+
label: "Skills",
|
|
363
|
+
icon: "book",
|
|
364
|
+
action: {
|
|
365
|
+
type: "navigate",
|
|
366
|
+
target: {
|
|
367
|
+
view: { extension: "core", id: "skills" },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
main: [
|
|
373
|
+
{
|
|
374
|
+
type: "section",
|
|
375
|
+
id: "feed-section",
|
|
376
|
+
title: "Change feed (newest 50, grouped by review)",
|
|
377
|
+
children: [
|
|
378
|
+
{
|
|
379
|
+
type: "markdown",
|
|
380
|
+
text: "Click a review to undo every change it made. Outside sigrun marks catch-up commits made outside sigrun.",
|
|
381
|
+
},
|
|
382
|
+
data.reviews.length === 0
|
|
383
|
+
? {
|
|
384
|
+
type: "markdown",
|
|
385
|
+
text: data.reviewMissed
|
|
386
|
+
? "This review is older than the recent changes."
|
|
387
|
+
: "Nothing learned yet. Use /learn to ask for a review now.",
|
|
388
|
+
}
|
|
389
|
+
: {
|
|
390
|
+
type: "collection",
|
|
391
|
+
id: "reviews",
|
|
392
|
+
display: "list",
|
|
393
|
+
items: data.reviews.map((group) => ({
|
|
394
|
+
id: group.id,
|
|
395
|
+
at: group.at,
|
|
396
|
+
summary: group.entries
|
|
397
|
+
.map((entry) => `${entry.action} ${entry.skill} (${entry.actor})`)
|
|
398
|
+
.join(", "),
|
|
399
|
+
})),
|
|
400
|
+
onActivate: { type: "edit", name: "undoReview" },
|
|
401
|
+
item: {
|
|
402
|
+
title: { $: "./id" },
|
|
403
|
+
subtitle: { $: "./summary" },
|
|
404
|
+
meta: [{ time: { $: "./at" }, format: "relative" }],
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@sigrun/ext-curator",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Curator extension for sigrun (pi-durable): the per-user fork review and idle maintenance pass over learned skills and curation targets",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.19.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node scripts/check-manifest.mjs --expect-name @sigrun/ext-curator && vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/chord": "1.3.0 || 1.4.0",
|
|
29
|
+
"@earendil-works/pi-ai": "1.3.0 || 1.4.0",
|
|
30
|
+
"@earendil-works/pi-durable": "1.3.0 || 1.4.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@earendil-works/chord": "1.4.0",
|
|
34
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
35
|
+
"@earendil-works/pi-durable": "1.4.0",
|
|
36
|
+
"@sigrun/sigrun": "file:../sigrun",
|
|
37
|
+
"@sigrun/shared": "file:../sigrun/shared",
|
|
38
|
+
"@types/node": "^26.6.4",
|
|
39
|
+
"tsx": "^4.23.15",
|
|
40
|
+
"typescript": "^7.0.2",
|
|
41
|
+
"vitest": "^5.0.3"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "github",
|
|
46
|
+
"url": "git+https://github.com/sigrun-ai/sigrun-ext-curator.git"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/sigrun-ai/sigrun-ext-curator/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/sigrun-ai/sigrun-ext-curator#readme",
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"provenance": true
|
|
55
|
+
}
|
|
56
|
+
}
|