@frockbot/plugin-skills 0.0.0 → 0.1.1
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/frockbot.json +15 -0
- package/package.json +36 -6
- package/src/agent.test.ts +360 -0
- package/src/agent.ts +678 -0
- package/src/catalog.test.ts +364 -0
- package/src/catalog.ts +704 -0
- package/src/index.ts +8 -0
- package/src/managed.ts +233 -0
- package/src/manifest.ts +3 -0
- package/src/plugin-index.ts +194 -0
- package/src/quota.ts +159 -0
- package/src/skill-md.test.ts +98 -0
- package/src/skill-md.ts +163 -0
- package/src/sources.test.ts +760 -0
- package/src/testing.ts +175 -0
- package/src/write.ts +181 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/testing.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// An in-memory `WorkspaceFilesV1`, for tests only.
|
|
2
|
+
//
|
|
3
|
+
// The Computer Package implements the real Workspace file surface; this
|
|
4
|
+
// Package deliberately implements none of it. This fake exists so the loader's
|
|
5
|
+
// behaviour can be proven against the contract rather than against a host.
|
|
6
|
+
import {
|
|
7
|
+
workspaceRootKeyV1,
|
|
8
|
+
type WorkspaceDeleteRequestV1,
|
|
9
|
+
type WorkspaceEntryV1,
|
|
10
|
+
type WorkspaceFilesV1,
|
|
11
|
+
type WorkspaceGenerationV1,
|
|
12
|
+
type WorkspaceListOutcomeV1,
|
|
13
|
+
type WorkspaceListRequestV1,
|
|
14
|
+
type WorkspacePathV1,
|
|
15
|
+
type WorkspaceReadOutcomeV1,
|
|
16
|
+
type WorkspaceRootV1,
|
|
17
|
+
type WorkspaceStatOutcomeV1,
|
|
18
|
+
type WorkspaceWriteOutcomeV1,
|
|
19
|
+
type WorkspaceWriteRequestV1,
|
|
20
|
+
type WorkspaceWriterV1,
|
|
21
|
+
} from "@frockbot/kernel-contracts";
|
|
22
|
+
|
|
23
|
+
export interface FakeWorkspaceSeedV1 {
|
|
24
|
+
root: WorkspaceRootV1;
|
|
25
|
+
path: string;
|
|
26
|
+
text: string;
|
|
27
|
+
writer: WorkspaceWriterV1;
|
|
28
|
+
generationId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function hashOf(bytes: Uint8Array): Promise<string> {
|
|
32
|
+
const digest = await crypto.subtle.digest(
|
|
33
|
+
"SHA-256",
|
|
34
|
+
bytes.slice().buffer as ArrayBuffer,
|
|
35
|
+
);
|
|
36
|
+
return [...new Uint8Array(digest)]
|
|
37
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
38
|
+
.join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface StoredFile {
|
|
42
|
+
entry: WorkspaceEntryV1;
|
|
43
|
+
bytes: Uint8Array;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A Workspace whose files live in a Map. Records every call it served. */
|
|
47
|
+
export class FakeWorkspace implements WorkspaceFilesV1 {
|
|
48
|
+
readonly calls: string[] = [];
|
|
49
|
+
#files = new Map<string, StoredFile>();
|
|
50
|
+
#sequence = 0;
|
|
51
|
+
/** Set to make `list` answer a failure, to exercise the unreadable path. */
|
|
52
|
+
listFailure?: { status: "unavailable" | "refused"; reason: string };
|
|
53
|
+
/** Entries per `list` page, so a caller's paging can be exercised. */
|
|
54
|
+
listPageSize = 100;
|
|
55
|
+
|
|
56
|
+
static async seeded(seeds: FakeWorkspaceSeedV1[]): Promise<FakeWorkspace> {
|
|
57
|
+
const workspace = new FakeWorkspace();
|
|
58
|
+
for (const seed of seeds) await workspace.seed(seed);
|
|
59
|
+
return workspace;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async seed(seed: FakeWorkspaceSeedV1): Promise<WorkspaceGenerationV1> {
|
|
63
|
+
const bytes = new TextEncoder().encode(seed.text);
|
|
64
|
+
const generation: WorkspaceGenerationV1 = {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
generationId: seed.generationId ?? this.#nextGenerationId(),
|
|
67
|
+
contentHash: await hashOf(bytes),
|
|
68
|
+
size: bytes.byteLength,
|
|
69
|
+
writer: seed.writer,
|
|
70
|
+
writtenAt: new Date(0).toISOString(),
|
|
71
|
+
};
|
|
72
|
+
const path: WorkspacePathV1 = { root: seed.root, path: seed.path };
|
|
73
|
+
this.#files.set(this.#key(path), { entry: { path, generation }, bytes });
|
|
74
|
+
return generation;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1> {
|
|
78
|
+
this.calls.push(`read:${path.path}`);
|
|
79
|
+
const stored = this.#files.get(this.#key(path));
|
|
80
|
+
if (!stored) {
|
|
81
|
+
return Promise.resolve({ status: "not-found", reason: "no such file" });
|
|
82
|
+
}
|
|
83
|
+
return Promise.resolve({
|
|
84
|
+
status: "ok",
|
|
85
|
+
file: { ...stored.entry, bytes: stored.bytes },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
stat(path: WorkspacePathV1): Promise<WorkspaceStatOutcomeV1> {
|
|
90
|
+
this.calls.push(`stat:${path.path}`);
|
|
91
|
+
const stored = this.#files.get(this.#key(path));
|
|
92
|
+
if (!stored) {
|
|
93
|
+
return Promise.resolve({ status: "not-found", reason: "no such file" });
|
|
94
|
+
}
|
|
95
|
+
return Promise.resolve({ status: "ok", entry: stored.entry });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
list(request: WorkspaceListRequestV1): Promise<WorkspaceListOutcomeV1> {
|
|
99
|
+
this.calls.push(`list:${workspaceRootKeyV1(request.root)}`);
|
|
100
|
+
if (this.listFailure) return Promise.resolve({ ...this.listFailure });
|
|
101
|
+
const key = workspaceRootKeyV1(request.root);
|
|
102
|
+
const all = [...this.#files.values()]
|
|
103
|
+
.filter((stored) => workspaceRootKeyV1(stored.entry.path.root) === key)
|
|
104
|
+
.map((stored) => stored.entry)
|
|
105
|
+
.sort((left, right) => left.path.path.localeCompare(right.path.path));
|
|
106
|
+
const start = request.cursor ? Number(request.cursor) : 0;
|
|
107
|
+
const size = request.limit ?? this.listPageSize;
|
|
108
|
+
const entries = all.slice(start, start + size);
|
|
109
|
+
const next = start + entries.length;
|
|
110
|
+
return Promise.resolve({
|
|
111
|
+
status: "ok",
|
|
112
|
+
entries,
|
|
113
|
+
...(next < all.length ? { cursor: String(next) } : {}),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async write(
|
|
118
|
+
request: WorkspaceWriteRequestV1,
|
|
119
|
+
): Promise<WorkspaceWriteOutcomeV1> {
|
|
120
|
+
this.calls.push(`write:${request.path.path}`);
|
|
121
|
+
const key = this.#key(request.path);
|
|
122
|
+
const existing = this.#files.get(key);
|
|
123
|
+
const seen = existing?.entry.generation.generationId ?? null;
|
|
124
|
+
if (seen !== request.expectedGenerationId) {
|
|
125
|
+
return {
|
|
126
|
+
status: "conflict",
|
|
127
|
+
reason: `expected generation ${request.expectedGenerationId ?? "none"}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const generation: WorkspaceGenerationV1 = {
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
generationId: this.#nextGenerationId(),
|
|
133
|
+
contentHash: await hashOf(request.bytes),
|
|
134
|
+
size: request.bytes.byteLength,
|
|
135
|
+
writer: request.writer,
|
|
136
|
+
writtenAt: new Date(0).toISOString(),
|
|
137
|
+
};
|
|
138
|
+
this.#files.set(key, {
|
|
139
|
+
entry: { path: request.path, generation },
|
|
140
|
+
bytes: request.bytes,
|
|
141
|
+
});
|
|
142
|
+
return { status: "ok", generation };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
delete(request: WorkspaceDeleteRequestV1): Promise<WorkspaceWriteOutcomeV1> {
|
|
146
|
+
this.calls.push(`delete:${request.path.path}`);
|
|
147
|
+
const key = this.#key(request.path);
|
|
148
|
+
const existing = this.#files.get(key);
|
|
149
|
+
if (!existing) {
|
|
150
|
+
return Promise.resolve({ status: "not-found", reason: "no such file" });
|
|
151
|
+
}
|
|
152
|
+
this.#files.delete(key);
|
|
153
|
+
return Promise.resolve({
|
|
154
|
+
status: "ok",
|
|
155
|
+
generation: existing.entry.generation,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#key(path: WorkspacePathV1): string {
|
|
160
|
+
return `${workspaceRootKeyV1(path.root)}|${path.path}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#nextGenerationId(): string {
|
|
164
|
+
this.#sequence += 1;
|
|
165
|
+
return `1970-01-01T00:00:00.000Z:${String(this.#sequence).padStart(16, "0")}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function skillMarkdown(
|
|
170
|
+
name: string,
|
|
171
|
+
description: string,
|
|
172
|
+
body: string,
|
|
173
|
+
): string {
|
|
174
|
+
return `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`;
|
|
175
|
+
}
|
package/src/write.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Writing one Skill document into one of a Bot's instruction roots.
|
|
2
|
+
//
|
|
3
|
+
// Two callers reach this, and they differ in exactly one thing: who the write
|
|
4
|
+
// is attributed to. `skill_write` writes as the Bot, inside an admitted Turn
|
|
5
|
+
// whose Session and Turn the provenance names. Importing a Bot template writes
|
|
6
|
+
// as the importing *User*, because a template is prose their User chose to
|
|
7
|
+
// materialize and no Turn of the new Bot has run yet.
|
|
8
|
+
//
|
|
9
|
+
// "The kernel treats every Workspace file as data. Only Skills under a Bot's
|
|
10
|
+
// instruction roots — its own and its User's — written under the Bot's own
|
|
11
|
+
// authority or its User's, are loaded as instructions." Both writers this
|
|
12
|
+
// module admits are on the right side of that sentence, and
|
|
13
|
+
// `isLoadableSkillSourceV1` is still the one place it is decided — this module
|
|
14
|
+
// cannot widen it, because both roots it can write are derived from the owner
|
|
15
|
+
// rather than passed in, so there is no argument with which to name another
|
|
16
|
+
// User's root or another Bot's.
|
|
17
|
+
import type {
|
|
18
|
+
WorkspaceFilesV1,
|
|
19
|
+
WorkspaceWriteRequestV1,
|
|
20
|
+
} from "@frockbot/kernel-contracts";
|
|
21
|
+
import {
|
|
22
|
+
botInstructionRootV1,
|
|
23
|
+
countSkillDocumentsV1,
|
|
24
|
+
userInstructionRootV1,
|
|
25
|
+
type SkillOwnerV1,
|
|
26
|
+
} from "./catalog.js";
|
|
27
|
+
import { renderSkillDocumentV1, skillDocumentPathV1 } from "./skill-md.js";
|
|
28
|
+
import {
|
|
29
|
+
checkSkillQuotaV1,
|
|
30
|
+
skillCountLimitV1,
|
|
31
|
+
SKILL_QUOTA_DEFAULTS_V1,
|
|
32
|
+
type SkillQuotaConfigV1,
|
|
33
|
+
type SkillQuotaScopeV1,
|
|
34
|
+
} from "./quota.js";
|
|
35
|
+
|
|
36
|
+
/** Who a Skill write is attributed to. Only these two are loadable. */
|
|
37
|
+
export type SkillDocumentWriterV1 =
|
|
38
|
+
| { kind: "user"; userId: string }
|
|
39
|
+
| {
|
|
40
|
+
kind: "bot";
|
|
41
|
+
botId: string;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
turnId: string;
|
|
44
|
+
runId: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface SkillDocumentDraftV1 {
|
|
48
|
+
slug: string;
|
|
49
|
+
name: string;
|
|
50
|
+
description: string;
|
|
51
|
+
body: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SkillWriteOutcomeV1 =
|
|
55
|
+
| {
|
|
56
|
+
status: "written";
|
|
57
|
+
path: string;
|
|
58
|
+
generationId: string;
|
|
59
|
+
contentHash: string;
|
|
60
|
+
/** True when the write superseded an existing generation at that path. */
|
|
61
|
+
replaced: boolean;
|
|
62
|
+
}
|
|
63
|
+
| { status: "refused"; reason: string };
|
|
64
|
+
|
|
65
|
+
export async function sha256HexV1(text: string): Promise<string> {
|
|
66
|
+
const digest = await crypto.subtle.digest(
|
|
67
|
+
"SHA-256",
|
|
68
|
+
new TextEncoder().encode(text),
|
|
69
|
+
);
|
|
70
|
+
return [...new Uint8Array(digest)]
|
|
71
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
72
|
+
.join("");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders and writes one Skill, enforcing that root's quota on the way.
|
|
77
|
+
*
|
|
78
|
+
* A refusal is a value, never a throw: a quota breach, an unreadable root, or a
|
|
79
|
+
* losing optimistic write are all outcomes a caller must record and report, and
|
|
80
|
+
* the two callers report them very differently — one as a tool result the model
|
|
81
|
+
* reads, one as a repairable step on a durable import record.
|
|
82
|
+
*/
|
|
83
|
+
export async function writeSkillDocumentV1(
|
|
84
|
+
files: WorkspaceFilesV1,
|
|
85
|
+
owner: SkillOwnerV1,
|
|
86
|
+
writer: SkillDocumentWriterV1,
|
|
87
|
+
draft: SkillDocumentDraftV1,
|
|
88
|
+
options: {
|
|
89
|
+
/**
|
|
90
|
+
* Which instruction root the Skill lands in: the Bot's own by default, or
|
|
91
|
+
* the User-global root every Bot of that User shares (ADR 0016). The
|
|
92
|
+
* writer is unchanged either way — a Bot writing the shared root still
|
|
93
|
+
* records itself, which is what lets a reading Bot be told whose Skill it
|
|
94
|
+
* is following.
|
|
95
|
+
*/
|
|
96
|
+
scope?: SkillQuotaScopeV1;
|
|
97
|
+
quota?: SkillQuotaConfigV1;
|
|
98
|
+
/**
|
|
99
|
+
* Recorded intent, after the quota admits the write and strictly before it
|
|
100
|
+
* runs. "Record durable execution intent before invoking an external side
|
|
101
|
+
* effect" — the Bot's tool appends `skill/write-intent` here, and the
|
|
102
|
+
* import saga marks its step in flight, so neither can be interrupted
|
|
103
|
+
* between deciding to write and having a record that it tried.
|
|
104
|
+
*/
|
|
105
|
+
onIntent?(intent: { path: string; contentHash: string }): Promise<void>;
|
|
106
|
+
} = {},
|
|
107
|
+
): Promise<SkillWriteOutcomeV1> {
|
|
108
|
+
const quota = options.quota ?? SKILL_QUOTA_DEFAULTS_V1;
|
|
109
|
+
const scope = options.scope ?? "bot";
|
|
110
|
+
const root =
|
|
111
|
+
scope === "user"
|
|
112
|
+
? userInstructionRootV1(owner)
|
|
113
|
+
: botInstructionRootV1(owner);
|
|
114
|
+
const relativePath = skillDocumentPathV1(draft.slug);
|
|
115
|
+
const path = { root, path: relativePath };
|
|
116
|
+
const text = renderSkillDocumentV1({
|
|
117
|
+
name: draft.name,
|
|
118
|
+
description: draft.description,
|
|
119
|
+
body: draft.body,
|
|
120
|
+
});
|
|
121
|
+
const bytes = new TextEncoder().encode(text);
|
|
122
|
+
|
|
123
|
+
const existing = await files.stat(path);
|
|
124
|
+
if (existing.status !== "ok" && existing.status !== "not-found") {
|
|
125
|
+
return {
|
|
126
|
+
status: "refused",
|
|
127
|
+
reason: `the instruction root is unavailable: ${existing.reason}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// The count is paged to completion, and a listing that cannot be read is a
|
|
131
|
+
// refusal rather than a zero: a quota that falls open is not a quota.
|
|
132
|
+
const counted = await countSkillDocumentsV1(files, path.root, {
|
|
133
|
+
stopAfter: skillCountLimitV1(scope, quota),
|
|
134
|
+
});
|
|
135
|
+
if (counted.status !== "ok") {
|
|
136
|
+
return {
|
|
137
|
+
status: "refused",
|
|
138
|
+
reason: `${counted.reason}, so the per-${
|
|
139
|
+
scope === "user" ? "User" : "Bot"
|
|
140
|
+
} Skill quota cannot be enforced`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const verdict = checkSkillQuotaV1(
|
|
144
|
+
{
|
|
145
|
+
bytes: bytes.byteLength,
|
|
146
|
+
existingSkills: counted.count,
|
|
147
|
+
replaces: existing.status === "ok",
|
|
148
|
+
scope,
|
|
149
|
+
},
|
|
150
|
+
quota,
|
|
151
|
+
);
|
|
152
|
+
if (verdict.status === "refused") {
|
|
153
|
+
return { status: "refused", reason: verdict.reason };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const contentHash = await sha256HexV1(text);
|
|
157
|
+
await options.onIntent?.({ path: relativePath, contentHash });
|
|
158
|
+
|
|
159
|
+
const request: WorkspaceWriteRequestV1 = {
|
|
160
|
+
path,
|
|
161
|
+
bytes,
|
|
162
|
+
writer,
|
|
163
|
+
expectedGenerationId:
|
|
164
|
+
existing.status === "ok" ? existing.entry.generation.generationId : null,
|
|
165
|
+
mediaType: "text/markdown",
|
|
166
|
+
};
|
|
167
|
+
const outcome = await files.write(request);
|
|
168
|
+
if (outcome.status !== "ok") {
|
|
169
|
+
return {
|
|
170
|
+
status: "refused",
|
|
171
|
+
reason: `the write was ${outcome.status}: ${outcome.reason}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
status: "written",
|
|
176
|
+
path: relativePath,
|
|
177
|
+
generationId: outcome.generation.generationId,
|
|
178
|
+
contentHash,
|
|
179
|
+
replaced: existing.status === "ok",
|
|
180
|
+
};
|
|
181
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED