@sigrun/sigrun 0.4.2 → 0.5.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 +37 -1
- package/README.md +46 -23
- package/dist/commands/expand.js +37 -5
- package/dist/curation/attribution.js +135 -0
- package/dist/curation/deletion.js +32 -0
- package/dist/curation/gate.js +50 -0
- package/dist/curation/host.js +342 -0
- package/dist/curation/internal.js +54 -0
- package/dist/curation/names.js +5 -0
- package/dist/curation/overlays.js +325 -0
- package/dist/curation/paths.js +17 -0
- package/dist/curation/run-doc.js +64 -0
- package/dist/curation/runs.js +579 -0
- package/dist/curation/scan.js +30 -0
- package/dist/curation/skill-tool.js +308 -0
- package/dist/curation/store.js +444 -0
- package/dist/curation/targets.js +77 -0
- package/dist/curation/usage.js +124 -0
- package/dist/curation/wrap.js +212 -0
- package/dist/extension.d.ts +282 -0
- package/dist/harness/boot.js +209 -9
- package/dist/harness/builtins.js +12 -9
- package/dist/harness/event-bus.js +64 -0
- package/dist/harness/loader.js +114 -1
- package/dist/harness/naming.js +6 -0
- package/dist/harness/packages.js +14 -9
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +139 -26
- package/dist/harness/settings.js +2 -2
- package/dist/harness/tools.js +70 -0
- package/dist/jobs/fire.js +33 -11
- package/dist/jobs/skills-refs.js +40 -0
- package/dist/sandbox/policy.js +14 -0
- package/dist/server/blocks/answers.js +9 -3
- package/dist/server/conversation-ops.js +21 -3
- package/dist/server/helpers.js +1 -1
- package/dist/server/host-bridge.js +6 -1
- package/dist/server/notifications.js +6 -0
- package/dist/server/routes/meta.js +8 -3
- package/dist/server/routes/submissions.js +5 -0
- package/dist/server/routes/surfaces.js +3 -1
- package/dist/server/routes/users.js +7 -0
- package/dist/server/schema-fixture.js +168 -0
- package/dist/server/session-meta.js +12 -10
- package/dist/settings/core-sections.js +9 -0
- package/dist/settings/registry.js +6 -0
- package/dist/skills/index.js +88 -19
- package/node_modules/@sigrun/shared/CHANGELOG.md +3 -1
- package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
- package/node_modules/@sigrun/shared/dist/access.js +8 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +9 -7
- package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
- package/webui/dist/index.html +1 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlays (curator P2 part 2, spec §6.2): a per-user addendum attached
|
|
3
|
+
* to a skill the user does NOT own — a workspace, user-dir or package
|
|
4
|
+
* skill. The upstream keeps flowing (updates, reinstalls); the overlay
|
|
5
|
+
* rides on top: `load_skill` and `/skill:name` serve the upstream body
|
|
6
|
+
* followed by the overlay text under {@link OVERLAY_HEADING}, and the
|
|
7
|
+
* skills section lists the skill once, unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Keying: root kind + skill name, plus the canonical workspace path for
|
|
10
|
+
* workspace skills (two workspaces can each own a skill of the same
|
|
11
|
+
* name). An overlay NEVER applies to a learned skill — the user owns
|
|
12
|
+
* that copy outright, so the store is the right tool (put throws).
|
|
13
|
+
*
|
|
14
|
+
* Storage: `curationDir/overlays/<root>/<sha256-of-key>.json`, one file
|
|
15
|
+
* per overlay (`Overlay` verbatim). A vanished upstream stops serving
|
|
16
|
+
* but the overlay is KEPT, marked `orphanedSince` by
|
|
17
|
+
* {@link OverlayStore.markOrphans} (present again clears the mark), and
|
|
18
|
+
* removable.
|
|
19
|
+
*
|
|
20
|
+
* Every overlay change is recorded in the SAME per-user change feed the
|
|
21
|
+
* {@link SkillStore} owns (A4): an entry with action `"overlay"` (the
|
|
22
|
+
* union member exists for exactly this), appended through the store's
|
|
23
|
+
* feed seam so retention and the per-user lock hold. The feed is the
|
|
24
|
+
* audit record and the P8 view's data; `OverlayStore` owns the files.
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
27
|
+
import { lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { curationDir, learnedSkillsDir } from "./paths.js";
|
|
30
|
+
import { validSkillName } from "./names.js";
|
|
31
|
+
/**
|
|
32
|
+
* The heading the served overlay follows: upstream body, then this
|
|
33
|
+
* heading, then the overlay text (one blank line each side of the
|
|
34
|
+
* heading).
|
|
35
|
+
*/
|
|
36
|
+
export const OVERLAY_HEADING = "## Your notes for this skill";
|
|
37
|
+
/** Directory label per root kind (no spaces in path segments). */
|
|
38
|
+
const ROOT_DIR = {
|
|
39
|
+
"workspace": "workspace",
|
|
40
|
+
"user dir": "user-dir",
|
|
41
|
+
"package": "package",
|
|
42
|
+
};
|
|
43
|
+
const ROOT_ORDER = ["workspace", "user dir", "package"];
|
|
44
|
+
/**
|
|
45
|
+
* The canonical key encoding: stable field order, `workspace` omitted
|
|
46
|
+
* for non-workspace keys. This is the input hashed into the file name —
|
|
47
|
+
* never change it silently (a change orphans every stored overlay).
|
|
48
|
+
*/
|
|
49
|
+
function keyString(key) {
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
root: key.root,
|
|
52
|
+
skill: key.skill,
|
|
53
|
+
...(key.workspace === undefined ? {} : { workspace: key.workspace }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** sha256 hex of the key encoding — the overlay's file name stem. */
|
|
57
|
+
function keyHash(key) {
|
|
58
|
+
return createHash("sha256").update(keyString(key)).digest("hex");
|
|
59
|
+
}
|
|
60
|
+
/** Validate a key on every store operation: a valid name, and a workspace path exactly for workspace keys. */
|
|
61
|
+
function checkKey(key) {
|
|
62
|
+
if (!validSkillName(key.skill))
|
|
63
|
+
throw new Error(`invalid skill name: ${key.skill}`);
|
|
64
|
+
if (key.root === "workspace" && (key.workspace === undefined || key.workspace === "")) {
|
|
65
|
+
throw new Error("a workspace overlay requires the workspace path");
|
|
66
|
+
}
|
|
67
|
+
if (key.root !== "workspace" && key.workspace !== undefined) {
|
|
68
|
+
throw new Error("the workspace path is only valid for a workspace overlay");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** sha256 hex of the upstream SKILL.md bytes. */
|
|
72
|
+
export function hashSkill(body) {
|
|
73
|
+
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The served shape: the upstream body, then {@link OVERLAY_HEADING},
|
|
77
|
+
* then the overlay text — one blank line each side of the heading.
|
|
78
|
+
*/
|
|
79
|
+
export function withOverlay(body, text) {
|
|
80
|
+
return `${body}\n${OVERLAY_HEADING}\n\n${text}`;
|
|
81
|
+
}
|
|
82
|
+
async function exists(path) {
|
|
83
|
+
try {
|
|
84
|
+
await lstat(path);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code === "ENOENT")
|
|
89
|
+
return false;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function writeFileAtomic(path, text) {
|
|
94
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
95
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
96
|
+
await writeFile(temp, text);
|
|
97
|
+
await rename(temp, path);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The per-user overlay store: files under
|
|
101
|
+
* `curationDir/overlays/<root>/<sha256-of-key>.json`, every change
|
|
102
|
+
* recorded in the skill store's feed as an `"overlay"` entry.
|
|
103
|
+
*/
|
|
104
|
+
export class OverlayStore {
|
|
105
|
+
options;
|
|
106
|
+
root;
|
|
107
|
+
constructor(options) {
|
|
108
|
+
this.options = options;
|
|
109
|
+
this.root = curationDir(options.userArea);
|
|
110
|
+
}
|
|
111
|
+
/** The one overlay for a key, `undefined` when none is stored. */
|
|
112
|
+
async get(key) {
|
|
113
|
+
this.requireUser();
|
|
114
|
+
checkKey(key);
|
|
115
|
+
return this.read(this.fileOf(key));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Create or replace the overlay for a key (the upstream hash pins
|
|
119
|
+
* what the text was written against; drift detection is the caller's
|
|
120
|
+
* job — `resolveUpstream` + `markOrphans`). Throws for a learned
|
|
121
|
+
* skill: the user owns that copy, so the store is the right tool.
|
|
122
|
+
* Returns the feed entry the change recorded.
|
|
123
|
+
*/
|
|
124
|
+
async put(key, text, upstreamHash, meta) {
|
|
125
|
+
this.requireUser();
|
|
126
|
+
checkKey(key);
|
|
127
|
+
if (await exists(join(learnedSkillsDir(this.options.userArea), key.skill))) {
|
|
128
|
+
throw new Error(`an overlay never applies to a learned skill: ${key.skill}`);
|
|
129
|
+
}
|
|
130
|
+
const overlay = { key, text, upstreamHash };
|
|
131
|
+
await writeFileAtomic(this.fileOf(key), `${JSON.stringify(overlay, null, 2)}\n`);
|
|
132
|
+
return this.appendFeed(key, meta, meta.reason ?? "overlay written");
|
|
133
|
+
}
|
|
134
|
+
/** Remove the overlay for a key; returns the feed entry. Throws when none is stored. */
|
|
135
|
+
async remove(key, meta) {
|
|
136
|
+
this.requireUser();
|
|
137
|
+
checkKey(key);
|
|
138
|
+
const file = this.fileOf(key);
|
|
139
|
+
if (!(await exists(file)))
|
|
140
|
+
throw new Error(`unknown overlay: ${keyString(key)}`);
|
|
141
|
+
await rm(file);
|
|
142
|
+
return this.appendFeed(key, meta, meta.reason ?? "overlay removed");
|
|
143
|
+
}
|
|
144
|
+
/** Every stored overlay (present upstreams and orphans alike), sorted by root then name. */
|
|
145
|
+
async list() {
|
|
146
|
+
this.requireUser();
|
|
147
|
+
const result = [];
|
|
148
|
+
for (const root of ROOT_ORDER) {
|
|
149
|
+
const dir = join(this.root, "overlays", ROOT_DIR[root]);
|
|
150
|
+
let entries = [];
|
|
151
|
+
try {
|
|
152
|
+
entries = (await readdir(dir)).filter((entry) => entry.endsWith(".json"));
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.code === "ENOENT")
|
|
156
|
+
continue;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
for (const entry of entries.sort()) {
|
|
160
|
+
const overlay = await this.read(join(dir, entry));
|
|
161
|
+
if (overlay !== undefined)
|
|
162
|
+
result.push(overlay);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Mark every overlay whose upstream is absent (per `present`) with
|
|
169
|
+
* `orphanedSince: now`, and CLEAR the mark when the upstream is back
|
|
170
|
+
* (the overlay resumes serving). `present` receives each stored key.
|
|
171
|
+
*/
|
|
172
|
+
async markOrphans(present, now) {
|
|
173
|
+
this.requireUser();
|
|
174
|
+
for (const overlay of await this.list()) {
|
|
175
|
+
const here = present(overlay.key);
|
|
176
|
+
if (here && overlay.orphanedSince === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
if (!here && overlay.orphanedSince !== undefined)
|
|
179
|
+
continue;
|
|
180
|
+
const next = here
|
|
181
|
+
? { key: overlay.key, text: overlay.text, upstreamHash: overlay.upstreamHash }
|
|
182
|
+
: { key: overlay.key, text: overlay.text, upstreamHash: overlay.upstreamHash, orphanedSince: overlay.orphanedSince ?? now };
|
|
183
|
+
await writeFileAtomic(this.fileOf(overlay.key), `${JSON.stringify(next, null, 2)}\n`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
requireUser() {
|
|
187
|
+
if (!this.options.userExists())
|
|
188
|
+
throw new Error("user removed");
|
|
189
|
+
}
|
|
190
|
+
fileOf(key) {
|
|
191
|
+
return join(this.root, "overlays", ROOT_DIR[key.root], `${keyHash(key)}.json`);
|
|
192
|
+
}
|
|
193
|
+
/** Read one overlay file; `undefined` on ENOENT (none stored). */
|
|
194
|
+
async read(path) {
|
|
195
|
+
let text;
|
|
196
|
+
try {
|
|
197
|
+
text = await readFile(path, "utf8");
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error.code === "ENOENT")
|
|
201
|
+
return undefined;
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
const value = JSON.parse(text);
|
|
205
|
+
if (typeof value.key?.root !== "string" ||
|
|
206
|
+
typeof value.key.skill !== "string" ||
|
|
207
|
+
typeof value.text !== "string" ||
|
|
208
|
+
typeof value.upstreamHash !== "string") {
|
|
209
|
+
throw new Error(`corrupt overlay file: ${path}`);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
key: value.key,
|
|
213
|
+
text: value.text,
|
|
214
|
+
upstreamHash: value.upstreamHash,
|
|
215
|
+
...(typeof value.orphanedSince === "string" ? { orphanedSince: value.orphanedSince } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** Record one overlay change in the skill store's feed (the `"overlay"` action). */
|
|
219
|
+
async appendFeed(key, meta, summary) {
|
|
220
|
+
const entry = {
|
|
221
|
+
id: randomUUID(),
|
|
222
|
+
at: new Date().toISOString(),
|
|
223
|
+
actor: meta.actor,
|
|
224
|
+
action: "overlay",
|
|
225
|
+
skill: key.skill,
|
|
226
|
+
reason: summary,
|
|
227
|
+
...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
|
|
228
|
+
...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
|
|
229
|
+
};
|
|
230
|
+
await this.options.feed.appendFeedEntry(entry);
|
|
231
|
+
return entry;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The CURRENT upstream SKILL.md for a key, read from its own root —
|
|
236
|
+
* core-side discovery: the workspace root through the conversation env
|
|
237
|
+
* (or the key's workspace path from the host filesystem), the user dir
|
|
238
|
+
* and the package roots through `opts`. `undefined` when the upstream
|
|
239
|
+
* is absent (vanished — the caller marks orphans).
|
|
240
|
+
*
|
|
241
|
+
* `opts` defaults to an empty user dir and no package roots: a
|
|
242
|
+
* workspace key needs only the env; a user-dir or package key needs the
|
|
243
|
+
* caller to hand the roots it discovered under (boot's skills options).
|
|
244
|
+
*/
|
|
245
|
+
export async function resolveUpstream(key, env, ctx, opts) {
|
|
246
|
+
checkKey(key);
|
|
247
|
+
const fromText = (text) => ({
|
|
248
|
+
body: text,
|
|
249
|
+
hash: hashSkill(text),
|
|
250
|
+
});
|
|
251
|
+
if (key.root === "workspace") {
|
|
252
|
+
// The conversation env sees the workspace the skill was discovered
|
|
253
|
+
// under; the key's canonical path is the host-side fallback (and
|
|
254
|
+
// the only route without an env).
|
|
255
|
+
if (env !== undefined) {
|
|
256
|
+
const joined = await env.joinPath(["skills", key.skill, "SKILL.md"], ctx);
|
|
257
|
+
if (joined.ok) {
|
|
258
|
+
const read = await env.readTextFile(joined.value, ctx);
|
|
259
|
+
if (read.ok)
|
|
260
|
+
return fromText(read.value);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (key.workspace !== undefined) {
|
|
264
|
+
const text = await readHost(join(key.workspace, "skills", key.skill, "SKILL.md"));
|
|
265
|
+
if (text !== undefined)
|
|
266
|
+
return fromText(text);
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
if (key.root === "user dir") {
|
|
271
|
+
const userDir = opts?.userDir;
|
|
272
|
+
if (userDir === undefined)
|
|
273
|
+
return undefined;
|
|
274
|
+
const text = await readHost(join(userDir, key.skill, "SKILL.md"));
|
|
275
|
+
return text === undefined ? undefined : fromText(text);
|
|
276
|
+
}
|
|
277
|
+
for (const root of opts?.packageRoots?.() ?? []) {
|
|
278
|
+
const text = await readHost(join(root.dir, key.skill, "SKILL.md"));
|
|
279
|
+
if (text !== undefined)
|
|
280
|
+
return fromText(text);
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
/** Read one host-local SKILL.md; `undefined` on ENOENT. */
|
|
285
|
+
async function readHost(path) {
|
|
286
|
+
try {
|
|
287
|
+
return await readFile(path, "utf8");
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error.code === "ENOENT")
|
|
291
|
+
return undefined;
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The overlay text to serve for one discovered skill, if any: looks the
|
|
297
|
+
* skill up in the owner's overlay store and skips orphans. A learned
|
|
298
|
+
* skill never has one (the store refuses those keys). Exported for the
|
|
299
|
+
* `/skill:name` expansion, which serves the same shape `load_skill`
|
|
300
|
+
* does.
|
|
301
|
+
*/
|
|
302
|
+
export async function overlayForSkill(opts, owner, skill, env) {
|
|
303
|
+
if (skill.root === "learned")
|
|
304
|
+
return undefined;
|
|
305
|
+
const store = opts.overlays?.(owner);
|
|
306
|
+
if (store === undefined)
|
|
307
|
+
return undefined;
|
|
308
|
+
// The canonical workspace key (curator A9): the ROOT path, never
|
|
309
|
+
// `env.cwd` — a conversation whose `pi.agent.cwd` points at a
|
|
310
|
+
// subdirectory must still see the overlay written against the
|
|
311
|
+
// workspace root. The root accessor wins when present; the env's
|
|
312
|
+
// cwd is the pre-A9 fallback (the hermetic single-user boot shape).
|
|
313
|
+
const workspace = skill.root === "workspace"
|
|
314
|
+
? (opts.workspaceRoot?.() ?? env?.cwd)
|
|
315
|
+
: undefined;
|
|
316
|
+
const key = {
|
|
317
|
+
root: skill.root,
|
|
318
|
+
skill: skill.name,
|
|
319
|
+
...(workspace === undefined ? {} : { workspace }),
|
|
320
|
+
};
|
|
321
|
+
const overlay = await store.get(key);
|
|
322
|
+
if (overlay === undefined || overlay.orphanedSince !== undefined)
|
|
323
|
+
return undefined;
|
|
324
|
+
return overlay.text;
|
|
325
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curator path helpers (P2): the per-user learned skills root and the
|
|
3
|
+
* per-user curation state directory, both derived from the managed user
|
|
4
|
+
* area (`<dataDir>/users/<user>` — `ManagedPaths.userAreas`).
|
|
5
|
+
*
|
|
6
|
+
* Pure derivations: no filesystem access, no validation — the sandbox
|
|
7
|
+
* policy owns creation and the grant validators own admission.
|
|
8
|
+
*/
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
/** The owner's learned skills root: `<userArea>/skills`. */
|
|
11
|
+
export function learnedSkillsDir(userArea) {
|
|
12
|
+
return join(userArea, "skills");
|
|
13
|
+
}
|
|
14
|
+
/** The owner's curation state directory: `<userArea>/curation`. */
|
|
15
|
+
export function curationDir(userArea) {
|
|
16
|
+
return join(userArea, "curation");
|
|
17
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curator run doc (P3/P4, spec §6.3): one conversation-scoped
|
|
3
|
+
* record identifying an INTERNAL curator run — the conversation A9's
|
|
4
|
+
* producer creates for a fork review or digest, whose tool calls the
|
|
5
|
+
* `skill_manage` tool (A6) executes mutating actions under.
|
|
6
|
+
*
|
|
7
|
+
* Shape (the A9 producer writes it in the conversation's CREATING
|
|
8
|
+
* commit, so it predates every observer):
|
|
9
|
+
*
|
|
10
|
+
* - `user` — the curated user the run acts FOR. The store user on every
|
|
11
|
+
* mutating `skill_manage` action is THIS value, never the calling
|
|
12
|
+
* principal (the run's parent may be shared by another user entirely).
|
|
13
|
+
* - `reviewId` — the review (or digest) the run belongs to.
|
|
14
|
+
* - `allow` — the action set the run may perform (P6's producer fills
|
|
15
|
+
* it; A6 reads it but does not enforce it — A8's wrapper owns the
|
|
16
|
+
* tool-level gate).
|
|
17
|
+
* - `parent` — the reviewed conversation (a fork review's source).
|
|
18
|
+
* - `viewed` — the read-before-write ledger: skill names the run has
|
|
19
|
+
* VIEWED through the tool. `markViewed` appends here through the tool
|
|
20
|
+
* call's own `api.commit` (memos are per tool task and die with the
|
|
21
|
+
* call, so the ledger must be a durable doc).
|
|
22
|
+
*/
|
|
23
|
+
import { defineDoc } from "@earendil-works/pi-durable";
|
|
24
|
+
/** The `sigrun.curation.run` doc (latest-only singleton per conversation). */
|
|
25
|
+
export const CurationRunDoc = defineDoc({
|
|
26
|
+
kind: "sigrun.curation.run",
|
|
27
|
+
version: 1,
|
|
28
|
+
scope: "conversation",
|
|
29
|
+
checkpointWhen: () => false,
|
|
30
|
+
history: "latest",
|
|
31
|
+
fork: "current",
|
|
32
|
+
initial: () => ({ user: "", reviewId: "", allow: [], viewed: [] }),
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* The run doc of one conversation, from any `DocumentReader` (a tool's
|
|
36
|
+
* `ToolExecutionApi` carries one). `undefined` when the conversation
|
|
37
|
+
* carries no run doc — an ordinary conversation, or a run conversation
|
|
38
|
+
* before the creating commit. Fail closed: a read failure also reads
|
|
39
|
+
* `undefined` (a mutating action then refuses, never guesses).
|
|
40
|
+
*/
|
|
41
|
+
export async function curationRun(read, conversationId, ctx) {
|
|
42
|
+
try {
|
|
43
|
+
return await read.snapshot(CurationRunDoc, conversationId, ctx);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Append one skill name to the run doc's `viewed` ledger, through the
|
|
51
|
+
* tool call's own `api.commit` — the read-before-write record the
|
|
52
|
+
* mutating actions check. Idempotent per name; a no-op when the
|
|
53
|
+
* conversation carries no run doc (nothing to record — `view` outside a
|
|
54
|
+
* run has no ledger to feed).
|
|
55
|
+
*/
|
|
56
|
+
export async function markViewed(api, name, ctx) {
|
|
57
|
+
await api.commit(async (tx) => {
|
|
58
|
+
const doc = await tx.doc(CurationRunDoc, api.conversationId);
|
|
59
|
+
if (doc.user === "")
|
|
60
|
+
return;
|
|
61
|
+
if (!doc.viewed.includes(name))
|
|
62
|
+
doc.viewed = [...doc.viewed, name];
|
|
63
|
+
}, ctx);
|
|
64
|
+
}
|