pi-roundtable 0.24.2 → 0.25.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 +11 -0
- package/docs/plugins.md +45 -1
- package/package.json +1 -1
- package/src/core/agents/agent-book.ts +155 -0
- package/src/core/agents/agent-ports.ts +5 -1
- package/src/core/agents/agent-schema.ts +3 -0
- package/src/core/agents/agent-statements.ts +179 -0
- package/src/core/agents/agent-store.ts +184 -191
- package/src/core/agents/agent-team-fixture.ts +21 -2
- package/src/core/agents/agent-team.ts +42 -36
- package/src/core/agents/agent-tools.ts +12 -6
- package/src/core/agents/team-editing.ts +150 -32
- package/src/core/agents/team-keys.ts +17 -0
- package/src/core/agents/team-lifecycle.ts +96 -23
- package/src/core/agents/team-options.ts +6 -3
- package/src/core/agents/team-selection.ts +28 -0
- package/src/core/builtin/agent-server.ts +29 -5
- package/src/core/builtin/prompts.ts +40 -0
- package/src/core/builtin/skills.ts +9 -5
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/i18n/agent-panel.ts +4 -0
- package/src/core/i18n/cards.ts +1 -1
- package/src/core/modules/prompts/prompt-documents.ts +425 -0
- package/src/core/modules/prompts/prompt-history.ts +103 -0
- package/src/core/modules/skills/skill-contract.ts +52 -0
- package/src/core/modules/skills/skill-document-files.ts +66 -0
- package/src/core/modules/skills/skill-registry.ts +136 -8
- package/src/core/modules/skills/skill-rules.ts +94 -0
- package/src/core/modules/skills/skill-store.ts +88 -17
- package/src/core/modules/skills/skill-tools.ts +18 -10
- package/src/core/runtime/extensions/agent-prompt.ts +20 -5
- package/src/core/runtime/extensions/persona-prompt.ts +39 -0
- package/src/core/runtime/extensions/prompt-read.ts +22 -0
- package/src/core/runtime/session-factory.ts +51 -35
- package/src/core/services.ts +88 -33
- package/src/index.ts +13 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "../../db/migrations.ts";
|
|
3
|
+
|
|
4
|
+
/** What a history belongs to: a prompt document, or the prompt of one agent. */
|
|
5
|
+
export type PromptKind = "document" | "agent";
|
|
6
|
+
|
|
7
|
+
/** `set` put new text in force; `revert` took a document back to its file version. */
|
|
8
|
+
export type PromptAction = "set" | "revert";
|
|
9
|
+
|
|
10
|
+
/** One change of a prompt, as it was made. */
|
|
11
|
+
export interface PromptVersion {
|
|
12
|
+
id: number;
|
|
13
|
+
action: PromptAction;
|
|
14
|
+
/** The text in force after the change; for a revert, the file version it went back to. */
|
|
15
|
+
text: string;
|
|
16
|
+
/** The text the change replaced. */
|
|
17
|
+
previous: string;
|
|
18
|
+
/** Who made it: the owner's name, or the agent that edited another agent's prompt. */
|
|
19
|
+
by: string;
|
|
20
|
+
at: Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The newest versions a prompt keeps; older ones are dropped as new ones come. */
|
|
24
|
+
export const PROMPT_HISTORY_KEEP = 200;
|
|
25
|
+
|
|
26
|
+
interface HistoryRow {
|
|
27
|
+
id: string | number;
|
|
28
|
+
action: PromptAction;
|
|
29
|
+
text: string;
|
|
30
|
+
previous: string;
|
|
31
|
+
by_name: string;
|
|
32
|
+
at: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The history table, shared by the documents and the agents; both create it, whichever comes first. */
|
|
36
|
+
export const promptHistoryMigration = (name: string): Migration => ({
|
|
37
|
+
name,
|
|
38
|
+
up: async (sql) => {
|
|
39
|
+
await sql`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS prompt_history (
|
|
41
|
+
id bigserial PRIMARY KEY,
|
|
42
|
+
guild_id text NOT NULL,
|
|
43
|
+
kind text NOT NULL,
|
|
44
|
+
name text NOT NULL,
|
|
45
|
+
action text NOT NULL,
|
|
46
|
+
text text NOT NULL,
|
|
47
|
+
previous text NOT NULL,
|
|
48
|
+
by_name text NOT NULL,
|
|
49
|
+
at timestamptz NOT NULL DEFAULT now()
|
|
50
|
+
)`;
|
|
51
|
+
await sql`CREATE INDEX IF NOT EXISTS prompt_history_of ON prompt_history (guild_id, kind, name, id)`;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Every change of a prompt, in the database, newest first. */
|
|
56
|
+
export class PromptHistory {
|
|
57
|
+
readonly #sql: SQL;
|
|
58
|
+
readonly #guild: string;
|
|
59
|
+
|
|
60
|
+
constructor(sql: SQL, guildId: string) {
|
|
61
|
+
this.#sql = sql;
|
|
62
|
+
this.#guild = guildId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Writes one version; `db` is a transaction when the change itself is written in one. */
|
|
66
|
+
async record(
|
|
67
|
+
kind: PromptKind,
|
|
68
|
+
name: string,
|
|
69
|
+
entry: Omit<PromptVersion, "id" | "at">,
|
|
70
|
+
db: SQL = this.#sql,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
await db`
|
|
73
|
+
INSERT INTO prompt_history (guild_id, kind, name, action, text, previous, by_name)
|
|
74
|
+
VALUES (${this.#guild}, ${kind}, ${name}, ${entry.action}, ${entry.text}, ${entry.previous}, ${entry.by})`;
|
|
75
|
+
await db`
|
|
76
|
+
DELETE FROM prompt_history
|
|
77
|
+
WHERE guild_id = ${this.#guild} AND kind = ${kind} AND name = ${name}
|
|
78
|
+
AND id NOT IN (
|
|
79
|
+
SELECT id FROM prompt_history
|
|
80
|
+
WHERE guild_id = ${this.#guild} AND kind = ${kind} AND name = ${name}
|
|
81
|
+
ORDER BY id DESC LIMIT ${PROMPT_HISTORY_KEEP})`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The newest versions first. */
|
|
85
|
+
async list(
|
|
86
|
+
kind: PromptKind,
|
|
87
|
+
name: string,
|
|
88
|
+
limit = PROMPT_HISTORY_KEEP,
|
|
89
|
+
): Promise<PromptVersion[]> {
|
|
90
|
+
const rows: HistoryRow[] = await this.#sql`
|
|
91
|
+
SELECT id, action, text, previous, by_name, at FROM prompt_history
|
|
92
|
+
WHERE guild_id = ${this.#guild} AND kind = ${kind} AND name = ${name}
|
|
93
|
+
ORDER BY id DESC LIMIT ${Math.min(Math.max(1, limit), PROMPT_HISTORY_KEEP)}`;
|
|
94
|
+
return rows.map((row) => ({
|
|
95
|
+
id: Number(row.id),
|
|
96
|
+
action: row.action,
|
|
97
|
+
text: row.text,
|
|
98
|
+
previous: row.previous,
|
|
99
|
+
by: row.by_name,
|
|
100
|
+
at: new Date(row.at),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SkillCatalogEntry,
|
|
3
|
+
SkillDocument,
|
|
4
|
+
SkillSet,
|
|
5
|
+
SkillVersion,
|
|
6
|
+
} from "./skill-rules.ts";
|
|
7
|
+
|
|
8
|
+
/** The skills agents carry: built in, linked from repositories, or written by an agent. */
|
|
9
|
+
export interface SkillRegistry {
|
|
10
|
+
/** The built-in skills plus the named registered ones, resolved to their files. */
|
|
11
|
+
resolve(names: readonly string[]): SkillSet;
|
|
12
|
+
/** What an agent carries, built-in skills included. */
|
|
13
|
+
carried(agent: string): SkillSet;
|
|
14
|
+
/** The registered skills an agent carries, as the dashboard lists them. */
|
|
15
|
+
carriedNames(agent: string): string[];
|
|
16
|
+
/** An agent's skills as `agent_get` shows them. */
|
|
17
|
+
describeCarried(agent: string): string;
|
|
18
|
+
/** Every skill, built-in ones first. */
|
|
19
|
+
catalog(): SkillCatalogEntry[];
|
|
20
|
+
/** Every skill, or those of a group or matching a query, as the `skill_list` tool shows them. */
|
|
21
|
+
list(filter?: { group?: string; query?: string }): string;
|
|
22
|
+
/** The skills linked from a managed repository. */
|
|
23
|
+
linkedFrom(repo: string): string[];
|
|
24
|
+
/** Throws AgentError when a name is not registered; built-in names are carried already. */
|
|
25
|
+
checkRegistered(names: readonly string[]): void;
|
|
26
|
+
/** Links the skill at a repository path, or every child folder with a SKILL.md. */
|
|
27
|
+
link(repo: string, path: string): Promise<string>;
|
|
28
|
+
/** A skill's SKILL.md, whole, with its hash and whether it is editable (only an agent-written skill is). Throws AgentError for an unknown skill or one whose file cannot be read. */
|
|
29
|
+
document(name: string): Promise<SkillDocument>;
|
|
30
|
+
/**
|
|
31
|
+
* Saves an agent-written skill's whole SKILL.md, edited from the one with hash `baseHash`; `by`
|
|
32
|
+
* is kept with the version. The name cannot change, and the description and body are checked as
|
|
33
|
+
* `skill_update` checks them. Throws `SkillConflictError` (with the current document) when the
|
|
34
|
+
* skill changed meanwhile and AgentError for a read-only skill or a refused text.
|
|
35
|
+
*/
|
|
36
|
+
saveDocument(
|
|
37
|
+
name: string,
|
|
38
|
+
text: string,
|
|
39
|
+
baseHash: string,
|
|
40
|
+
by: string,
|
|
41
|
+
): Promise<SkillDocument>;
|
|
42
|
+
/** Makes a skill from a whole SKILL.md, as `skill_create` does; it is attached to no one. Throws AgentError for a taken or refused name or text. */
|
|
43
|
+
createDocument(text: string, by: string): Promise<SkillDocument>;
|
|
44
|
+
/** The saved versions of an agent-written skill, newest first; none for any other skill. */
|
|
45
|
+
documentHistory(name: string): Promise<SkillVersion[]>;
|
|
46
|
+
/** Adds and removes an agent's registered skills together, or changes nothing; returns what it carries after. */
|
|
47
|
+
attach(
|
|
48
|
+
agent: string,
|
|
49
|
+
add: readonly string[],
|
|
50
|
+
remove: readonly string[],
|
|
51
|
+
): Promise<string[]>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { AgentError } from "../../domain/errors.ts";
|
|
3
|
+
import {
|
|
4
|
+
hashOf,
|
|
5
|
+
MAX_DOCUMENT_BYTES,
|
|
6
|
+
type SkillDocument,
|
|
7
|
+
type SkillSource,
|
|
8
|
+
type SkillVersion,
|
|
9
|
+
skillFileContent,
|
|
10
|
+
} from "./skill-rules.ts";
|
|
11
|
+
import type { SkillEntry, StoredSkillVersion } from "./skill-store.ts";
|
|
12
|
+
|
|
13
|
+
/** An agent-written skill as the owner edits it: its SKILL.md, with the hash a save is checked against. */
|
|
14
|
+
export function writtenDocument(
|
|
15
|
+
entry: Extract<SkillEntry, { kind: "written" }>,
|
|
16
|
+
): SkillDocument {
|
|
17
|
+
const text = skillFileContent(entry);
|
|
18
|
+
return {
|
|
19
|
+
name: entry.name,
|
|
20
|
+
source: { kind: "written" },
|
|
21
|
+
text,
|
|
22
|
+
hash: hashOf(text),
|
|
23
|
+
writable: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A skill the owner may read but not edit: its file as it is on disk, if it is small enough to show. */
|
|
28
|
+
export function readSkillDocument(
|
|
29
|
+
name: string,
|
|
30
|
+
source: SkillSource,
|
|
31
|
+
file: string,
|
|
32
|
+
): SkillDocument {
|
|
33
|
+
let size: number;
|
|
34
|
+
try {
|
|
35
|
+
size = statSync(file).size;
|
|
36
|
+
} catch {
|
|
37
|
+
throw new AgentError(`The file of the skill "${name}" cannot be read.`);
|
|
38
|
+
}
|
|
39
|
+
if (size > MAX_DOCUMENT_BYTES)
|
|
40
|
+
throw new AgentError(
|
|
41
|
+
`The file of the skill "${name}" is too large to show.`,
|
|
42
|
+
);
|
|
43
|
+
const text = readFileSync(file, "utf8");
|
|
44
|
+
return { name, source, text, hash: hashOf(text), writable: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The stored versions of a written skill as whole documents, in the order given. */
|
|
48
|
+
export function skillVersions(
|
|
49
|
+
name: string,
|
|
50
|
+
rows: readonly StoredSkillVersion[],
|
|
51
|
+
): SkillVersion[] {
|
|
52
|
+
return rows.map((version) => {
|
|
53
|
+
const text = skillFileContent({
|
|
54
|
+
name,
|
|
55
|
+
description: version.description,
|
|
56
|
+
body: version.body,
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
id: version.id,
|
|
60
|
+
text,
|
|
61
|
+
hash: hashOf(text),
|
|
62
|
+
by: version.by,
|
|
63
|
+
at: version.at,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -3,6 +3,11 @@ import { join } from "node:path";
|
|
|
3
3
|
import { AgentError } from "../../domain/errors.ts";
|
|
4
4
|
import { PluginError } from "../../errors.ts";
|
|
5
5
|
import type { SkillRegistry } from "../../services.ts";
|
|
6
|
+
import {
|
|
7
|
+
readSkillDocument,
|
|
8
|
+
skillVersions,
|
|
9
|
+
writtenDocument,
|
|
10
|
+
} from "./skill-document-files.ts";
|
|
6
11
|
import { planLinkedSkills } from "./skill-link.ts";
|
|
7
12
|
import { skillCatalog, skillListText } from "./skill-listing.ts";
|
|
8
13
|
import {
|
|
@@ -11,11 +16,16 @@ import {
|
|
|
11
16
|
checkDescription,
|
|
12
17
|
checkSkillName,
|
|
13
18
|
type PackageSkill,
|
|
19
|
+
parseSkillDocument,
|
|
14
20
|
type ResolvedSkill,
|
|
15
21
|
readSkillFile,
|
|
16
22
|
type SkillCatalogEntry,
|
|
23
|
+
SkillConflictError,
|
|
24
|
+
type SkillDocument,
|
|
17
25
|
type SkillRegistryOptions,
|
|
18
26
|
type SkillSet,
|
|
27
|
+
type SkillSource,
|
|
28
|
+
type SkillVersion,
|
|
19
29
|
skillFileContent,
|
|
20
30
|
} from "./skill-rules.ts";
|
|
21
31
|
import type { SkillEntry } from "./skill-store.ts";
|
|
@@ -27,15 +37,20 @@ export {
|
|
|
27
37
|
type PackageSkill,
|
|
28
38
|
type ResolvedSkill,
|
|
29
39
|
type SkillCatalogEntry,
|
|
40
|
+
SkillConflictError,
|
|
41
|
+
type SkillDocument,
|
|
30
42
|
type SkillRegistryOptions,
|
|
31
43
|
type SkillSet,
|
|
32
44
|
type SkillSource,
|
|
45
|
+
type SkillVersion,
|
|
33
46
|
} from "./skill-rules.ts";
|
|
34
47
|
|
|
35
48
|
/** The skill registry and what each agent carries. */
|
|
36
49
|
export class StoredSkillRegistry implements SkillRegistry {
|
|
37
50
|
readonly #options: SkillRegistryOptions;
|
|
38
51
|
readonly #packageSkills = new Map<string, PackageSkill>();
|
|
52
|
+
/** The tail of the document writes, so a hash is checked and the skill written without another save between. */
|
|
53
|
+
#documents: Promise<unknown> = Promise.resolve();
|
|
39
54
|
|
|
40
55
|
/** Throws a PluginError when a package skill has the name of a linked or written one. */
|
|
41
56
|
constructor(options: SkillRegistryOptions) {
|
|
@@ -169,11 +184,29 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
169
184
|
return this.#removed(name, await this.#options.store.remove(name));
|
|
170
185
|
}
|
|
171
186
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Writes a new skill. It takes its turn in the queue of the document writes, so it cannot
|
|
189
|
+
* land between an owner's hash check and the write that follows it.
|
|
190
|
+
*/
|
|
191
|
+
create(
|
|
192
|
+
skill: {
|
|
193
|
+
name: string;
|
|
194
|
+
description: string;
|
|
195
|
+
body: string;
|
|
196
|
+
},
|
|
197
|
+
by = "agent",
|
|
198
|
+
): Promise<string> {
|
|
199
|
+
return this.#inDocuments(() => this.#create(skill, by));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async #create(
|
|
203
|
+
skill: {
|
|
204
|
+
name: string;
|
|
205
|
+
description: string;
|
|
206
|
+
body: string;
|
|
207
|
+
},
|
|
208
|
+
by: string,
|
|
209
|
+
): Promise<string> {
|
|
177
210
|
checkSkillName(skill.name);
|
|
178
211
|
this.#checkFreeName(skill.name);
|
|
179
212
|
checkDescription(skill.description);
|
|
@@ -183,14 +216,27 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
183
216
|
description: skill.description.trim(),
|
|
184
217
|
body: skill.body.trim(),
|
|
185
218
|
};
|
|
186
|
-
await this.#options.store.addWritten(entry);
|
|
219
|
+
await this.#options.store.addWritten(entry, by);
|
|
187
220
|
this.#write(entry);
|
|
188
221
|
return `Created skill ${skill.name}. Attach it with agent_skills; it loads from each carrier's next turn.`;
|
|
189
222
|
}
|
|
190
223
|
|
|
191
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Replaces an agent-written skill's description or body. It takes its turn in the queue of the
|
|
226
|
+
* document writes, so an owner's save from a hash it passed can never overwrite it unseen.
|
|
227
|
+
*/
|
|
228
|
+
update(
|
|
192
229
|
name: string,
|
|
193
230
|
change: { description?: string; body?: string },
|
|
231
|
+
by = "agent",
|
|
232
|
+
): Promise<string> {
|
|
233
|
+
return this.#inDocuments(() => this.#update(name, change, by));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async #update(
|
|
237
|
+
name: string,
|
|
238
|
+
change: { description?: string; body?: string },
|
|
239
|
+
by: string,
|
|
194
240
|
): Promise<string> {
|
|
195
241
|
const entry = this.#writable(name);
|
|
196
242
|
if (change.description !== undefined) checkDescription(change.description);
|
|
@@ -200,11 +246,93 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
200
246
|
description: change.description?.trim() ?? entry.description,
|
|
201
247
|
body: change.body?.trim() ?? entry.body,
|
|
202
248
|
};
|
|
203
|
-
await this.#options.store.updateWritten(next);
|
|
249
|
+
await this.#options.store.updateWritten(next, by);
|
|
204
250
|
this.#write(next);
|
|
205
251
|
return `Updated skill ${name}; its carriers read the new version from their next turn.`;
|
|
206
252
|
}
|
|
207
253
|
|
|
254
|
+
// ── Documents: a SKILL.md as the owner edits it in the web app ─────────
|
|
255
|
+
|
|
256
|
+
/** A skill's SKILL.md; throws AgentError for an unknown skill or one whose file cannot be read. */
|
|
257
|
+
async document(name: string): Promise<SkillDocument> {
|
|
258
|
+
const source = this.#sourceOf(name);
|
|
259
|
+
const entry = this.#options.store.skill(name);
|
|
260
|
+
if (entry?.kind === "written") return writtenDocument(entry);
|
|
261
|
+
const file = this.#fileOf(name);
|
|
262
|
+
if (!source || !file) throw new AgentError(`There is no skill "${name}".`);
|
|
263
|
+
return readSkillDocument(name, source, file);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Saves an agent-written skill's whole SKILL.md as edited from the one with hash `baseHash`.
|
|
268
|
+
* The name cannot change; the description and body are checked as the skill tools check them.
|
|
269
|
+
* Throws SkillConflictError, carrying the current document, when the skill is no longer the
|
|
270
|
+
* one that was edited, and AgentError for a read-only skill or a refused text.
|
|
271
|
+
*/
|
|
272
|
+
saveDocument(
|
|
273
|
+
name: string,
|
|
274
|
+
text: string,
|
|
275
|
+
baseHash: string,
|
|
276
|
+
by: string,
|
|
277
|
+
): Promise<SkillDocument> {
|
|
278
|
+
return this.#inDocuments(async () => {
|
|
279
|
+
const entry = this.#writable(name);
|
|
280
|
+
const current = writtenDocument(entry);
|
|
281
|
+
if (current.hash !== baseHash) throw new SkillConflictError(current);
|
|
282
|
+
const next = parseSkillDocument(text);
|
|
283
|
+
if (next.name !== name)
|
|
284
|
+
throw new AgentError(
|
|
285
|
+
`The name of a skill cannot change (it is "${name}"). To use another name, create a new skill.`,
|
|
286
|
+
);
|
|
287
|
+
if (next.description === entry.description && next.body === entry.body)
|
|
288
|
+
return current;
|
|
289
|
+
await this.#update(
|
|
290
|
+
name,
|
|
291
|
+
{ description: next.description, body: next.body },
|
|
292
|
+
by,
|
|
293
|
+
);
|
|
294
|
+
return this.document(name);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Makes a skill from a whole SKILL.md; it is attached to no one. Throws AgentError for a taken or refused name or text. */
|
|
299
|
+
createDocument(text: string, by: string): Promise<SkillDocument> {
|
|
300
|
+
return this.#inDocuments(async () => {
|
|
301
|
+
const made = parseSkillDocument(text);
|
|
302
|
+
await this.#create(made, by);
|
|
303
|
+
return this.document(made.name);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The saved versions of an agent-written skill, newest first; none for any other skill. */
|
|
308
|
+
async documentHistory(name: string): Promise<SkillVersion[]> {
|
|
309
|
+
const entry = this.#options.store.skill(name);
|
|
310
|
+
if (entry?.kind !== "written") {
|
|
311
|
+
this.#sourceOf(name);
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
return skillVersions(name, await this.#options.store.versions(name));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#inDocuments<T>(write: () => Promise<T>): Promise<T> {
|
|
318
|
+
const run = this.#documents.then(write);
|
|
319
|
+
this.#documents = run.catch(() => undefined);
|
|
320
|
+
return run;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Where a skill comes from; throws AgentError for a name nobody registered. */
|
|
324
|
+
#sourceOf(name: string): SkillSource {
|
|
325
|
+
if ((BUILTIN_SKILLS as readonly string[]).includes(name))
|
|
326
|
+
return { kind: "builtin" };
|
|
327
|
+
const fromPackage = this.#packageSkills.get(name);
|
|
328
|
+
if (fromPackage) return { kind: "package", package: fromPackage.package };
|
|
329
|
+
const entry = this.#options.store.skill(name);
|
|
330
|
+
if (!entry) throw new AgentError(`There is no skill "${name}".`);
|
|
331
|
+
return entry.kind === "linked"
|
|
332
|
+
? { kind: "linked", repo: entry.repo, path: entry.path }
|
|
333
|
+
: { kind: "written" };
|
|
334
|
+
}
|
|
335
|
+
|
|
208
336
|
async delete(name: string): Promise<string> {
|
|
209
337
|
this.#writable(name);
|
|
210
338
|
const detached = await this.#options.store.remove(name);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { AgentError } from "../../domain/errors.ts";
|
|
@@ -130,3 +131,96 @@ export const skillFileContent = (entry: {
|
|
|
130
131
|
|
|
131
132
|
export const cut = (text: string, max: number) =>
|
|
132
133
|
text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
134
|
+
|
|
135
|
+
/** The most bytes of a SKILL.md the registry reads for the web app. */
|
|
136
|
+
export const MAX_DOCUMENT_BYTES = 64 * 1024;
|
|
137
|
+
|
|
138
|
+
/** A skill's SKILL.md as the owner reads and edits it. */
|
|
139
|
+
export interface SkillDocument {
|
|
140
|
+
name: string;
|
|
141
|
+
source: SkillSource;
|
|
142
|
+
/** The whole file: frontmatter and body. */
|
|
143
|
+
text: string;
|
|
144
|
+
/** SHA-256 of `text`; a save names the hash it was edited from. */
|
|
145
|
+
hash: string;
|
|
146
|
+
/** An agent-written skill; the others are read-only here. */
|
|
147
|
+
writable: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One saved version of an agent-written skill's SKILL.md. */
|
|
151
|
+
export interface SkillVersion {
|
|
152
|
+
id: number;
|
|
153
|
+
text: string;
|
|
154
|
+
hash: string;
|
|
155
|
+
/** Who saved it: the owner's name, or `agent` for a skill tool. */
|
|
156
|
+
by: string;
|
|
157
|
+
at: Date;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A save made from a SKILL.md that is no longer the current one; `current` is what is there now. */
|
|
161
|
+
export class SkillConflictError extends Error {
|
|
162
|
+
override name = "SkillConflictError";
|
|
163
|
+
constructor(readonly current: SkillDocument) {
|
|
164
|
+
super(`The skill "${current.name}" changed while it was being edited.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const hashOf = (text: string): string =>
|
|
169
|
+
createHash("sha256").update(text).digest("hex");
|
|
170
|
+
|
|
171
|
+
/** The fields a SKILL.md holds when the registry writes it; any other frontmatter key is refused. */
|
|
172
|
+
const FRONTMATTER_KEYS = ["name", "description"] as const;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reads a SKILL.md the owner wrote: frontmatter with a `name` and a `description`, then the body.
|
|
176
|
+
* The description and body are checked as the skill tools check them (`checkDescription`,
|
|
177
|
+
* `checkBody`), and a key besides the two is refused rather than dropped when the file is written
|
|
178
|
+
* again. Throws AgentError with the reason.
|
|
179
|
+
*/
|
|
180
|
+
export function parseSkillDocument(text: string): {
|
|
181
|
+
name: string;
|
|
182
|
+
description: string;
|
|
183
|
+
body: string;
|
|
184
|
+
} {
|
|
185
|
+
const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
186
|
+
if (!normalized.startsWith("---\n") || !normalized.includes("\n---", 3))
|
|
187
|
+
throw new AgentError(
|
|
188
|
+
"A SKILL.md starts with frontmatter between two --- lines, holding name and description.",
|
|
189
|
+
);
|
|
190
|
+
let parsed: { frontmatter: Record<string, unknown>; body: string };
|
|
191
|
+
try {
|
|
192
|
+
parsed = parseFrontmatter<Record<string, unknown>>(normalized);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new AgentError(
|
|
195
|
+
`The frontmatter is not valid YAML: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const { frontmatter, body } = parsed;
|
|
199
|
+
if (
|
|
200
|
+
typeof frontmatter !== "object" ||
|
|
201
|
+
frontmatter === null ||
|
|
202
|
+
Array.isArray(frontmatter)
|
|
203
|
+
)
|
|
204
|
+
throw new AgentError(
|
|
205
|
+
"The frontmatter must be keys and values: name and description.",
|
|
206
|
+
);
|
|
207
|
+
const extra = Object.keys(frontmatter).filter(
|
|
208
|
+
(key) => !(FRONTMATTER_KEYS as readonly string[]).includes(key),
|
|
209
|
+
);
|
|
210
|
+
if (extra.length > 0)
|
|
211
|
+
throw new AgentError(
|
|
212
|
+
`The frontmatter has ${extra.map((key) => `"${key}"`).join(", ")}; only name and description are kept.`,
|
|
213
|
+
);
|
|
214
|
+
const { name, description } = frontmatter;
|
|
215
|
+
if (typeof name !== "string" || !name.trim())
|
|
216
|
+
throw new AgentError("The frontmatter needs a name.");
|
|
217
|
+
if (typeof description !== "string" || !description.trim())
|
|
218
|
+
throw new AgentError("The frontmatter needs a description.");
|
|
219
|
+
checkDescription(description);
|
|
220
|
+
checkBody(body);
|
|
221
|
+
return {
|
|
222
|
+
name: name.trim(),
|
|
223
|
+
description: description.trim(),
|
|
224
|
+
body: body.trim(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -18,6 +18,18 @@ export interface SkillGroup {
|
|
|
18
18
|
skills: string[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** One saved version of a written skill. */
|
|
22
|
+
export interface StoredSkillVersion {
|
|
23
|
+
id: number;
|
|
24
|
+
description: string;
|
|
25
|
+
body: string;
|
|
26
|
+
by: string;
|
|
27
|
+
at: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The newest versions of a written skill the store keeps. */
|
|
31
|
+
export const SKILL_VERSIONS_KEEP = 50;
|
|
32
|
+
|
|
21
33
|
/** Who lost a removed skill. */
|
|
22
34
|
export interface Detached {
|
|
23
35
|
agents: string[];
|
|
@@ -39,6 +51,14 @@ interface GroupRow {
|
|
|
39
51
|
skills: string;
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
interface VersionRow {
|
|
55
|
+
id: string | number;
|
|
56
|
+
description: string;
|
|
57
|
+
body: string;
|
|
58
|
+
by_name: string;
|
|
59
|
+
at: Date;
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
const toEntry = (row: SkillRow): SkillEntry =>
|
|
43
63
|
row.kind === "linked"
|
|
44
64
|
? {
|
|
@@ -121,6 +141,22 @@ export class SkillStore {
|
|
|
121
141
|
static migrations(guildId: string): Migration[] {
|
|
122
142
|
return [
|
|
123
143
|
SkillStore.migration,
|
|
144
|
+
// The versions of the written skills, kept for the web app's history.
|
|
145
|
+
{
|
|
146
|
+
name: "skill-versions",
|
|
147
|
+
up: async (sql) => {
|
|
148
|
+
await sql`
|
|
149
|
+
CREATE TABLE IF NOT EXISTS skill_versions (
|
|
150
|
+
id bigserial PRIMARY KEY,
|
|
151
|
+
name text NOT NULL,
|
|
152
|
+
description text NOT NULL,
|
|
153
|
+
body text NOT NULL,
|
|
154
|
+
by_name text NOT NULL,
|
|
155
|
+
at timestamptz NOT NULL DEFAULT now()
|
|
156
|
+
)`;
|
|
157
|
+
await sql`CREATE INDEX IF NOT EXISTS skill_versions_of ON skill_versions (name, id)`;
|
|
158
|
+
},
|
|
159
|
+
},
|
|
124
160
|
{
|
|
125
161
|
name: "skills-guild",
|
|
126
162
|
up: (sql) =>
|
|
@@ -225,35 +261,69 @@ export class SkillStore {
|
|
|
225
261
|
});
|
|
226
262
|
}
|
|
227
263
|
|
|
228
|
-
async addWritten(
|
|
229
|
-
name: string;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}): Promise<void> {
|
|
264
|
+
async addWritten(
|
|
265
|
+
skill: { name: string; description: string; body: string },
|
|
266
|
+
by = "agent",
|
|
267
|
+
): Promise<void> {
|
|
233
268
|
return this.#serial(async () => {
|
|
234
269
|
if (this.#skills.has(skill.name))
|
|
235
270
|
throw new AgentError(`A skill named "${skill.name}" already exists.`);
|
|
236
|
-
await this.#sql
|
|
237
|
-
|
|
238
|
-
|
|
271
|
+
await this.#sql.begin(async (tx) => {
|
|
272
|
+
await tx`
|
|
273
|
+
INSERT INTO skills (name, kind, description, body)
|
|
274
|
+
VALUES (${skill.name}, 'written', ${skill.description}, ${skill.body})`;
|
|
275
|
+
await this.#keepVersion(tx, skill, by);
|
|
276
|
+
});
|
|
239
277
|
this.#skills.set(skill.name, { kind: "written", ...skill });
|
|
240
278
|
});
|
|
241
279
|
}
|
|
242
280
|
|
|
243
|
-
async updateWritten(
|
|
244
|
-
name: string;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}): Promise<void> {
|
|
281
|
+
async updateWritten(
|
|
282
|
+
skill: { name: string; description: string; body: string },
|
|
283
|
+
by = "agent",
|
|
284
|
+
): Promise<void> {
|
|
248
285
|
return this.#serial(async () => {
|
|
249
|
-
await this.#sql
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
286
|
+
await this.#sql.begin(async (tx) => {
|
|
287
|
+
await tx`
|
|
288
|
+
UPDATE skills SET description = ${skill.description}, body = ${skill.body},
|
|
289
|
+
updated_at = now()
|
|
290
|
+
WHERE name = ${skill.name} AND kind = 'written'`;
|
|
291
|
+
await this.#keepVersion(tx, skill, by);
|
|
292
|
+
});
|
|
253
293
|
this.#skills.set(skill.name, { kind: "written", ...skill });
|
|
254
294
|
});
|
|
255
295
|
}
|
|
256
296
|
|
|
297
|
+
/** Writes a version of a skill and drops the ones beyond the newest `SKILL_VERSIONS_KEEP`. */
|
|
298
|
+
async #keepVersion(
|
|
299
|
+
tx: SQL,
|
|
300
|
+
skill: { name: string; description: string; body: string },
|
|
301
|
+
by: string,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
await tx`
|
|
304
|
+
INSERT INTO skill_versions (name, description, body, by_name)
|
|
305
|
+
VALUES (${skill.name}, ${skill.description}, ${skill.body}, ${by})`;
|
|
306
|
+
await tx`
|
|
307
|
+
DELETE FROM skill_versions
|
|
308
|
+
WHERE name = ${skill.name} AND id NOT IN (
|
|
309
|
+
SELECT id FROM skill_versions WHERE name = ${skill.name}
|
|
310
|
+
ORDER BY id DESC LIMIT ${SKILL_VERSIONS_KEEP})`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The saved versions of a written skill, newest first. */
|
|
314
|
+
async versions(name: string): Promise<StoredSkillVersion[]> {
|
|
315
|
+
const rows: VersionRow[] = await this.#sql`
|
|
316
|
+
SELECT id, description, body, by_name, at FROM skill_versions
|
|
317
|
+
WHERE name = ${name} ORDER BY id DESC LIMIT ${SKILL_VERSIONS_KEEP}`;
|
|
318
|
+
return rows.map((row) => ({
|
|
319
|
+
id: Number(row.id),
|
|
320
|
+
description: row.description,
|
|
321
|
+
body: row.body,
|
|
322
|
+
by: row.by_name,
|
|
323
|
+
at: new Date(row.at),
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
|
|
257
327
|
/** Removes a skill and detaches it from every agent and group. */
|
|
258
328
|
async remove(name: string): Promise<Detached> {
|
|
259
329
|
return this.#serial(async () => {
|
|
@@ -263,6 +333,7 @@ export class SkillStore {
|
|
|
263
333
|
);
|
|
264
334
|
await this.#sql.begin(async (tx) => {
|
|
265
335
|
await tx`DELETE FROM skills WHERE name = ${name}`;
|
|
336
|
+
await tx`DELETE FROM skill_versions WHERE name = ${name}`;
|
|
266
337
|
await tx`DELETE FROM agent_skills WHERE guild_id = ${this.#guild} AND skill = ${name}`;
|
|
267
338
|
for (const group of groups)
|
|
268
339
|
await tx`
|