@sigrun/sigrun 0.6.1 → 0.7.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 +92 -0
- package/README.md +105 -18
- package/dist/blocks/build-stamp.json +2 -2
- package/dist/blocks/schema.js +5 -0
- package/dist/cli.js +85 -22
- package/dist/curation/host.js +46 -21
- package/dist/curation/migrate.js +96 -0
- package/dist/curation/overlays.js +52 -16
- package/dist/curation/paths.js +22 -0
- package/dist/curation/runs.js +34 -17
- package/dist/curation/skill-tool.js +34 -9
- package/dist/curation/store.js +382 -254
- package/dist/curation/targets.js +21 -0
- package/dist/curation/wrap.js +1 -6
- package/dist/extension.d.ts +12 -3
- package/dist/harness/boot.js +143 -6
- package/dist/harness/event-bus.js +19 -0
- package/dist/harness/loader.js +22 -0
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +71 -16
- package/dist/harness/workspace.js +34 -1
- package/dist/history/git.js +707 -0
- package/dist/history/trailers.js +145 -0
- package/dist/history/workspace-history.js +47 -0
- package/dist/jobs/index.js +5 -3
- package/dist/jobs/reconcile.js +2 -3
- package/dist/jobs/state-dir.js +9 -0
- package/dist/jobs/transfer.js +6 -6
- package/dist/sandbox/grants.js +11 -0
- package/dist/sandbox/launch.js +19 -9
- package/dist/sandbox/policy.js +6 -1
- package/dist/serve.js +3 -0
- package/dist/server/auth/proxy-only.js +5 -4
- package/dist/server/core-views/index.js +26 -0
- package/dist/server/core-views/jobs.js +661 -0
- package/dist/server/core-views/skills-tree.js +628 -0
- package/dist/server/core-views/skills.js +1090 -0
- package/dist/server/index.js +88 -0
- package/dist/server/jobs-access.js +62 -0
- package/dist/server/notifications.js +24 -12
- package/dist/server/routes/extensions.js +9 -1
- package/dist/server/routes/jobs.js +19 -52
- package/dist/server/routes/meta.js +9 -0
- package/dist/server/routes/onboarding.js +13 -1
- package/dist/server/surfaces/service.js +38 -0
- package/dist/skills/index.js +1 -1
- package/dist/skills/roots.js +301 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
- package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
- package/node_modules/@sigrun/shared/dist/auth.js +26 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
- package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-BI5k-Oci.js +55 -0
- package/webui/dist/assets/index-EGluyRXk.css +1 -0
- package/webui/dist/favicon.svg +7 -5
- package/webui/dist/index.html +3 -3
- package/webui/dist/assets/index-BR0di0OG.css +0 -1
- package/webui/dist/assets/index-BzrA1T4g.js +0 -55
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curation history migration (tabs+skills plan, Task 9): a per-user
|
|
3
|
+
* one-shot import of the pre-git curation store into the user's git
|
|
4
|
+
* history repo.
|
|
5
|
+
*
|
|
6
|
+
* Runs at boot for every directory user AND lazily the first time
|
|
7
|
+
* `curationOf` constructs a user's stores (users created after boot).
|
|
8
|
+
* The marker `curation/history/MIGRATED` is written LAST: without it,
|
|
9
|
+
* any partial `user.git` is discarded and the import is redone. The
|
|
10
|
+
* legacy artifacts (`feed.jsonl`, `versions/`) are never touched — they
|
|
11
|
+
* stay read-only on disk and outside the repo's pathspec.
|
|
12
|
+
*
|
|
13
|
+
* The discard + import run inside `GitHistory.freshImport` (the
|
|
14
|
+
* per-repo lock): a store write concurrent with the migration either
|
|
15
|
+
* lands before the discard (its commit is discarded and its content is
|
|
16
|
+
* re-imported from disk — nothing lost) or after the import (its own
|
|
17
|
+
* commit on top). The unlocked rm of an earlier revision could delete
|
|
18
|
+
* a repo a concurrent write had just committed (review I3).
|
|
19
|
+
*
|
|
20
|
+
* A "nothing" result ALSO writes the marker (whole-branch review C1):
|
|
21
|
+
* without it, an empty user's first write commits fine, but the NEXT
|
|
22
|
+
* boot's sweep sees a non-empty pathspec with no marker and freshImport
|
|
23
|
+
* would discard that entire first-session history. The marker's "nothing
|
|
24
|
+
* was imported" form is the same file; marker-last ordering is trivially
|
|
25
|
+
* preserved (nothing precedes it).
|
|
26
|
+
*
|
|
27
|
+
* The residual failure window: a crash AFTER freshImport's commit but
|
|
28
|
+
* BEFORE the marker write leaves an unmarked repo holding real import
|
|
29
|
+
* commits — the next boot discards it and re-imports from disk (the
|
|
30
|
+
* content is safe), but any feed entry / undo anchor / review link that
|
|
31
|
+
* already recorded the discarded commits' shas dangles (their trailers
|
|
32
|
+
* are lost with the discarded commits). The window is one writeFile
|
|
33
|
+
* wide; closing it would need the marker inside the commit itself.
|
|
34
|
+
*/
|
|
35
|
+
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { historyDir, userRepoSpec } from "./paths.js";
|
|
38
|
+
/**
|
|
39
|
+
* Import the user's current curation store into the history repo.
|
|
40
|
+
* "already" when the marker exists; "nothing" when the user has no
|
|
41
|
+
* tracked content at all (the marker IS written — review C1: an
|
|
42
|
+
* unmarked empty user loses their first write's history on the next
|
|
43
|
+
* boot's sweep); "migrated" otherwise.
|
|
44
|
+
*
|
|
45
|
+
* A `user.git` without the marker on a repo this call did not just
|
|
46
|
+
* build is a partial migration: it is discarded and the import runs
|
|
47
|
+
* fresh.
|
|
48
|
+
*/
|
|
49
|
+
export async function migrateUserCuration(userArea, history) {
|
|
50
|
+
const marker = join(historyDir(userArea), "MIGRATED");
|
|
51
|
+
if (await present(marker))
|
|
52
|
+
return "already";
|
|
53
|
+
if (!(await pathspecContent(userArea))) {
|
|
54
|
+
// Nothing to import — but the marker must exist anyway: an
|
|
55
|
+
// unmarked user's first write commits into a repo the next boot
|
|
56
|
+
// would treat as a partial migration and discard (review C1).
|
|
57
|
+
await mkdir(historyDir(userArea), { recursive: true });
|
|
58
|
+
await writeFile(marker, `${new Date().toISOString()}\n`);
|
|
59
|
+
return "nothing";
|
|
60
|
+
}
|
|
61
|
+
// The discard + import are ONE locked operation (GitHistory.
|
|
62
|
+
// freshImport): a concurrent store write either lands before the
|
|
63
|
+
// discard (its content is re-imported from disk — nothing lost) or
|
|
64
|
+
// after the import (its own commit on top). The work tree must exist
|
|
65
|
+
// before the git dir can init inside it.
|
|
66
|
+
await mkdir(historyDir(userArea), { recursive: true });
|
|
67
|
+
const sha = await history.freshImport({
|
|
68
|
+
actor: { kind: "outside" },
|
|
69
|
+
summary: "Imported from the curation store",
|
|
70
|
+
});
|
|
71
|
+
if (sha === undefined) {
|
|
72
|
+
// Git failed (fail-soft): no commit, no marker — the next boot
|
|
73
|
+
// retries the whole import.
|
|
74
|
+
return "nothing";
|
|
75
|
+
}
|
|
76
|
+
await mkdir(historyDir(userArea), { recursive: true });
|
|
77
|
+
await writeFile(marker, `${new Date().toISOString()}\n`);
|
|
78
|
+
return "migrated";
|
|
79
|
+
}
|
|
80
|
+
/** Whether any pathspec entry exists in the work tree. */
|
|
81
|
+
async function pathspecContent(userArea) {
|
|
82
|
+
for (const entry of userRepoSpec(userArea).pathspec) {
|
|
83
|
+
if (await present(join(userArea, entry)))
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
async function present(path) {
|
|
89
|
+
try {
|
|
90
|
+
await access(path);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -17,17 +17,25 @@
|
|
|
17
17
|
* {@link OverlayStore.markOrphans} (present again clears the mark), and
|
|
18
18
|
* removable.
|
|
19
19
|
*
|
|
20
|
-
* Every overlay change
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* audit record and the P8 view's data; `OverlayStore` owns the files.
|
|
20
|
+
* Every overlay change commits to the user's curation history repo
|
|
21
|
+
* (tabs+skills Task 9): one commit per put/remove with the Sigrun-*
|
|
22
|
+
* trailers, so overlay changes appear in the same feed as learned-skill
|
|
23
|
+
* changes. `OverlayStore` owns the files.
|
|
25
24
|
*/
|
|
26
25
|
import { randomUUID, createHash } from "node:crypto";
|
|
27
26
|
import { lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
28
27
|
import { join } from "node:path";
|
|
29
28
|
import { curationDir, learnedSkillsDir } from "./paths.js";
|
|
30
29
|
import { validSkillName } from "./names.js";
|
|
30
|
+
/** The Sigrun actor shape for a history commit. */
|
|
31
|
+
function historyActor(actor, user) {
|
|
32
|
+
switch (actor) {
|
|
33
|
+
case "user": return { kind: "user", user };
|
|
34
|
+
case "curator": return { kind: "curator" };
|
|
35
|
+
case "agent": return { kind: "agent" };
|
|
36
|
+
case "outside": return { kind: "outside" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
31
39
|
/**
|
|
32
40
|
* The heading the served overlay follows: upstream body, then this
|
|
33
41
|
* heading, then the overlay text (one blank line each side of the
|
|
@@ -123,23 +131,52 @@ export class OverlayStore {
|
|
|
123
131
|
*/
|
|
124
132
|
async put(key, text, upstreamHash, meta) {
|
|
125
133
|
this.requireUser();
|
|
134
|
+
await this.options.ready?.();
|
|
126
135
|
checkKey(key);
|
|
127
136
|
if (await exists(join(learnedSkillsDir(this.options.userArea), key.skill))) {
|
|
128
137
|
throw new Error(`an overlay never applies to a learned skill: ${key.skill}`);
|
|
129
138
|
}
|
|
139
|
+
// The repo's work tree must exist before the git dir can init.
|
|
140
|
+
await mkdir(curationDir(this.options.userArea), { recursive: true });
|
|
130
141
|
const overlay = { key, text, upstreamHash };
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
const summary = `overlay ${key.root}:${key.skill}`;
|
|
143
|
+
const sha = await this.options.history.write({
|
|
144
|
+
actor: historyActor(meta.actor, this.options.user),
|
|
145
|
+
summary,
|
|
146
|
+
action: "overlay",
|
|
147
|
+
skill: key.skill,
|
|
148
|
+
...(meta.reason === undefined ? {} : { reason: meta.reason }),
|
|
149
|
+
...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
|
|
150
|
+
...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
|
|
151
|
+
}, async () => {
|
|
152
|
+
this.requireUser();
|
|
153
|
+
await writeFileAtomic(this.fileOf(key), `${JSON.stringify(overlay, null, 2)}\n`);
|
|
154
|
+
}, { kind: "outside" });
|
|
155
|
+
return this.entry(key, sha, meta, summary);
|
|
133
156
|
}
|
|
134
157
|
/** Remove the overlay for a key; returns the feed entry. Throws when none is stored. */
|
|
135
158
|
async remove(key, meta) {
|
|
136
159
|
this.requireUser();
|
|
160
|
+
await this.options.ready?.();
|
|
137
161
|
checkKey(key);
|
|
138
162
|
const file = this.fileOf(key);
|
|
139
163
|
if (!(await exists(file)))
|
|
140
164
|
throw new Error(`unknown overlay: ${keyString(key)}`);
|
|
141
|
-
await
|
|
142
|
-
|
|
165
|
+
await mkdir(curationDir(this.options.userArea), { recursive: true });
|
|
166
|
+
const summary = `overlay ${key.root}:${key.skill}`;
|
|
167
|
+
const sha = await this.options.history.write({
|
|
168
|
+
actor: historyActor(meta.actor, this.options.user),
|
|
169
|
+
summary,
|
|
170
|
+
action: "overlay",
|
|
171
|
+
skill: key.skill,
|
|
172
|
+
...(meta.reason === undefined ? {} : { reason: meta.reason }),
|
|
173
|
+
...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
|
|
174
|
+
...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
|
|
175
|
+
}, async () => {
|
|
176
|
+
this.requireUser();
|
|
177
|
+
await rm(file);
|
|
178
|
+
}, { kind: "outside" });
|
|
179
|
+
return this.entry(key, sha, meta, summary);
|
|
143
180
|
}
|
|
144
181
|
/** Every stored overlay (present upstreams and orphans alike), sorted by root then name. */
|
|
145
182
|
async list() {
|
|
@@ -215,20 +252,19 @@ export class OverlayStore {
|
|
|
215
252
|
...(typeof value.orphanedSince === "string" ? { orphanedSince: value.orphanedSince } : {}),
|
|
216
253
|
};
|
|
217
254
|
}
|
|
218
|
-
/** Record one overlay change in the
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
id: randomUUID()
|
|
255
|
+
/** Record one overlay change in the user repo as an `"overlay"` entry. */
|
|
256
|
+
entry(key, sha, meta, summary) {
|
|
257
|
+
return {
|
|
258
|
+
id: sha ?? `(no-commit-${randomUUID()})`,
|
|
222
259
|
at: new Date().toISOString(),
|
|
223
260
|
actor: meta.actor,
|
|
224
261
|
action: "overlay",
|
|
225
262
|
skill: key.skill,
|
|
226
|
-
reason: summary,
|
|
263
|
+
reason: meta.reason ?? summary,
|
|
227
264
|
...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
|
|
228
265
|
...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
|
|
266
|
+
...(sha === undefined ? {} : { after: sha }),
|
|
229
267
|
};
|
|
230
|
-
await this.options.feed.appendFeedEntry(entry);
|
|
231
|
-
return entry;
|
|
232
268
|
}
|
|
233
269
|
}
|
|
234
270
|
/**
|
package/dist/curation/paths.js
CHANGED
|
@@ -15,3 +15,25 @@ export function learnedSkillsDir(userArea) {
|
|
|
15
15
|
export function curationDir(userArea) {
|
|
16
16
|
return join(userArea, "curation");
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* The owner's curation history root: `<userArea>/curation/history`
|
|
20
|
+
* (tabs+skills Task 9). Holds `user.git` (the per-user curation repo),
|
|
21
|
+
* later `workspace.git` (Task 10), and the `MIGRATED` marker. A plain
|
|
22
|
+
* directory, never a git work tree.
|
|
23
|
+
*/
|
|
24
|
+
export function historyDir(userArea) {
|
|
25
|
+
return join(userArea, "curation", "history");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The ONE per-user curation repo (tabs+skills Task 9): skills,
|
|
29
|
+
* archive, overlays and state in one history, with `userArea` as the
|
|
30
|
+
* work tree. `feed.jsonl` and `versions/` are deliberately OUTSIDE the
|
|
31
|
+
* pathspec — read-only legacy after migration.
|
|
32
|
+
*/
|
|
33
|
+
export function userRepoSpec(userArea) {
|
|
34
|
+
return {
|
|
35
|
+
gitDir: join(historyDir(userArea), "user.git"),
|
|
36
|
+
workTree: userArea,
|
|
37
|
+
pathspec: ["skills", "curation/archive", "curation/overlays", "curation/state.json"],
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/curation/runs.js
CHANGED
|
@@ -433,28 +433,45 @@ export async function driveRun(deps, prepare, opts, parent) {
|
|
|
433
433
|
}
|
|
434
434
|
}
|
|
435
435
|
/** Collect the run's `details.curation` changes, in entry order. */
|
|
436
|
+
/** Scan bound for collectChanges' cursor walk (a broken cursor loops, a count does not). */
|
|
437
|
+
const COLLECT_SCAN_LIMIT = 10_000;
|
|
436
438
|
async function collectChanges(conversation) {
|
|
437
|
-
const page = await conversation.entries({ order: "ascending" }, 500, undefined, BACKGROUND_CONTEXT);
|
|
438
439
|
const changes = [];
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
typeof curation.summary !== "string") {
|
|
440
|
+
// Cursor-complete walk (the A9 ruling): a run with more tool results
|
|
441
|
+
// than one page loses nothing — pages until exhaustion, bounded so
|
|
442
|
+
// a cursor bug cannot loop forever.
|
|
443
|
+
let cursor;
|
|
444
|
+
let scanned = 0;
|
|
445
|
+
for (;;) {
|
|
446
|
+
const page = await conversation.entries({ order: "ascending" }, 500, cursor, BACKGROUND_CONTEXT);
|
|
447
|
+
for (const entry of page.items) {
|
|
448
|
+
scanned += 1;
|
|
449
|
+
if (entry.kind !== ToolResultEntry.kind)
|
|
450
450
|
continue;
|
|
451
|
+
for (const message of entry.model ?? []) {
|
|
452
|
+
if (message.role !== "toolResult")
|
|
453
|
+
continue;
|
|
454
|
+
const details = message.details;
|
|
455
|
+
const curation = details?.curation;
|
|
456
|
+
if (curation === undefined ||
|
|
457
|
+
typeof curation.changeId !== "string" ||
|
|
458
|
+
typeof curation.summary !== "string") {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
changes.push({
|
|
462
|
+
changeId: curation.changeId,
|
|
463
|
+
summary: curation.summary,
|
|
464
|
+
target: targetOfTool(message.toolName),
|
|
465
|
+
});
|
|
451
466
|
}
|
|
452
|
-
changes.push({
|
|
453
|
-
changeId: curation.changeId,
|
|
454
|
-
summary: curation.summary,
|
|
455
|
-
target: targetOfTool(message.toolName),
|
|
456
|
-
});
|
|
457
467
|
}
|
|
468
|
+
if (page.next === undefined || page.items.length === 0)
|
|
469
|
+
break;
|
|
470
|
+
if (scanned >= COLLECT_SCAN_LIMIT) {
|
|
471
|
+
console.warn(`curation: collectChanges hit the ${COLLECT_SCAN_LIMIT}-entry scan guard — the run's change list is a PREFIX (later changes are unreported)`);
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
cursor = page.next;
|
|
458
475
|
}
|
|
459
476
|
return changes;
|
|
460
477
|
}
|
|
@@ -29,6 +29,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
29
29
|
import { defineTool } from "@earendil-works/pi-durable";
|
|
30
30
|
import { OVERLAY_HEADING, withOverlay, } from "./overlays.js";
|
|
31
31
|
import { curationRun, markViewed } from "./run-doc.js";
|
|
32
|
+
import { SKILL_MANAGE_READ_ACTIONS } from "./targets.js";
|
|
32
33
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
33
34
|
/** The tool's parameter union (the brief's exact shape). */
|
|
34
35
|
const skillManageParameters = Type.Object({
|
|
@@ -111,7 +112,10 @@ async function viewUpstreamOnly(deps, stores, args, env, ctx) {
|
|
|
111
112
|
};
|
|
112
113
|
const upstream = await deps.upstreamOf(key, env, ctx).catch(() => undefined);
|
|
113
114
|
if (stored === undefined && upstream === undefined) {
|
|
114
|
-
return
|
|
115
|
+
return {
|
|
116
|
+
found: false,
|
|
117
|
+
message: `unknown skill "${args.name}" — no learned copy, no stored overlay, and no upstream resolved in the ${key.root} root.`,
|
|
118
|
+
};
|
|
115
119
|
}
|
|
116
120
|
const notes = [
|
|
117
121
|
`(no learned copy of "${args.name}"; showing the ${key.root} upstream${stored === undefined ? "" : " with the stored overlay"})`,
|
|
@@ -129,7 +133,7 @@ async function viewUpstreamOnly(deps, stores, args, env, ctx) {
|
|
|
129
133
|
else {
|
|
130
134
|
body = withOverlay(upstream.body, stored.text);
|
|
131
135
|
}
|
|
132
|
-
return `${notes.join(" ")}\n\n${body}
|
|
136
|
+
return { found: true, body: `${notes.join(" ")}\n\n${body}` };
|
|
133
137
|
}
|
|
134
138
|
/** Render the store's `list` result as the model-facing text. */
|
|
135
139
|
function renderList(skills) {
|
|
@@ -155,7 +159,7 @@ export function buildSkillManageTool(deps) {
|
|
|
155
159
|
// Ownerless conversations: nothing to read (and no user to write
|
|
156
160
|
// for — the mutating refusal below covers those too, but the read
|
|
157
161
|
// answer is the one the brief pins).
|
|
158
|
-
if (
|
|
162
|
+
if (SKILL_MANAGE_READ_ACTIONS.has(args.action)) {
|
|
159
163
|
if (owner === undefined) {
|
|
160
164
|
return error("This conversation has no owner, so there are no learned skills to inspect.");
|
|
161
165
|
}
|
|
@@ -169,18 +173,29 @@ export function buildSkillManageTool(deps) {
|
|
|
169
173
|
}
|
|
170
174
|
if (args.name === undefined)
|
|
171
175
|
return error("view requires a skill name.");
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
// The learned read: ENOENT (no learned copy) falls through to
|
|
177
|
+
// the upstream/overlay serving below; ANY OTHER store failure
|
|
178
|
+
// (a permissions error, a corrupt root) must surface, never
|
|
179
|
+
// fail open into a phantom "unknown skill".
|
|
180
|
+
let learned;
|
|
181
|
+
try {
|
|
182
|
+
learned = await stores.store.view(args.name, args.file_path ?? "SKILL.md");
|
|
183
|
+
}
|
|
184
|
+
catch (cause) {
|
|
185
|
+
if (cause?.code !== "ENOENT") {
|
|
186
|
+
throw cause;
|
|
187
|
+
}
|
|
188
|
+
learned = undefined;
|
|
189
|
+
}
|
|
175
190
|
if (learned === undefined) {
|
|
176
191
|
// Upstream-only names (the overlay lifecycle): serve the stored
|
|
177
192
|
// overlay and/or the live upstream — without this fallback the
|
|
178
193
|
// learned-root ENOENT traps an overlay update/remove in an
|
|
179
194
|
// unrecoverable "view X first" → "unknown skill" loop.
|
|
180
195
|
const served = await viewUpstreamOnly(deps, stores, { name: args.name, overlay_root: args.overlay_root }, api.env, context);
|
|
181
|
-
if (served.
|
|
182
|
-
return error(served);
|
|
183
|
-
api.output(served);
|
|
196
|
+
if (!served.found)
|
|
197
|
+
return error(served.message);
|
|
198
|
+
api.output(served.body);
|
|
184
199
|
}
|
|
185
200
|
else {
|
|
186
201
|
api.output(learned);
|
|
@@ -223,6 +238,16 @@ export function buildSkillManageTool(deps) {
|
|
|
223
238
|
conversationId: api.conversationId,
|
|
224
239
|
...(args.reason === undefined ? {} : { reason: args.reason }),
|
|
225
240
|
};
|
|
241
|
+
// Empty content: a create/write_file carrying no text records a
|
|
242
|
+
// phantom empty entry (the cleanup-pass C1). Refuse UP FRONT, at
|
|
243
|
+
// the tool boundary, naming the empty content — the store's
|
|
244
|
+
// frontmatter check would otherwise answer a create with the
|
|
245
|
+
// misleading "frontmatter is required", and a write_file would
|
|
246
|
+
// silently land an empty support file plus its feed entry.
|
|
247
|
+
if ((args.action === "create" || args.action === "write_file") &&
|
|
248
|
+
(args.content ?? "") === "") {
|
|
249
|
+
return error(`${args.action} ${args.name}: empty content — nothing to write.`);
|
|
250
|
+
}
|
|
226
251
|
// Read-before-write: an existing item must carry its name in the
|
|
227
252
|
// run doc's viewed ledger. A skill that does not exist yet (a
|
|
228
253
|
// create, or a first overlay) needs no prior view.
|