@yaklabs-ai/plugin-sdk 0.8.4
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/LICENSE +21 -0
- package/README.md +205 -0
- package/THIRD-PARTY-INVENTORY.json +65 -0
- package/THIRD-PARTY-NOTICES.md +103 -0
- package/dist/disclosure.d.ts +3431 -0
- package/dist/disclosure.js +873 -0
- package/dist/kay-agent-testing.d.ts +3294 -0
- package/dist/kay-agent-testing.js +88 -0
- package/dist/kay-agent.d.ts +5286 -0
- package/dist/kay-agent.js +1341 -0
- package/dist/kay-app.d.ts +6303 -0
- package/dist/kay-app.js +424 -0
- package/dist/phase-shift.d.ts +127 -0
- package/dist/phase-shift.js +180 -0
- package/dist/transcript.d.ts +60 -0
- package/dist/transcript.js +23 -0
- package/package.json +59 -0
- package/skills/directory-plugin-authoring/SKILL.md +252 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// packages/plugin-protocol/dist/phase-shift.js
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// packages/plugin-protocol/dist/transcript.js
|
|
5
|
+
function branchEntryIds(entries) {
|
|
6
|
+
const last = entries[entries.length - 1];
|
|
7
|
+
if (last === void 0)
|
|
8
|
+
return EMPTY_BRANCH_IDS;
|
|
9
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
10
|
+
const onBranch = /* @__PURE__ */ new Set();
|
|
11
|
+
let cursor = last.type === "leaf" ? last.targetId ?? null : last.id;
|
|
12
|
+
while (cursor !== null && !onBranch.has(cursor)) {
|
|
13
|
+
onBranch.add(cursor);
|
|
14
|
+
cursor = byId.get(cursor)?.parentId ?? null;
|
|
15
|
+
}
|
|
16
|
+
return onBranch;
|
|
17
|
+
}
|
|
18
|
+
var EMPTY_BRANCH_IDS = /* @__PURE__ */ new Set();
|
|
19
|
+
|
|
20
|
+
// packages/plugin-protocol/dist/phase-shift.js
|
|
21
|
+
var PHASE_SHIFT_EXTENSION_ID = "kay.phase-shift";
|
|
22
|
+
var PHASE_SHIFT_ENTRY_NAME = "observation";
|
|
23
|
+
var PHASE_SHIFT_CUSTOM_TYPE = `ext:${PHASE_SHIFT_EXTENSION_ID}:${PHASE_SHIFT_ENTRY_NAME}`;
|
|
24
|
+
var PHASE_SHIFT_TIMELINE_CUSTOM_TYPE = `ext:${PHASE_SHIFT_EXTENSION_ID}:timeline`;
|
|
25
|
+
var PHASE_SHIFT_DETECTOR_VERSION = "activity-memory-v1";
|
|
26
|
+
var entryId = z.string().min(1).max(256);
|
|
27
|
+
var purpose = z.string().trim().min(1).max(320);
|
|
28
|
+
var sourceTime = z.iso.datetime();
|
|
29
|
+
var phaseShiftMemorySchema = z.object({
|
|
30
|
+
startEntryId: entryId,
|
|
31
|
+
purpose,
|
|
32
|
+
anchors: z.array(z.object({ entryId, excerpt: z.string().max(250) })).min(1).max(3)
|
|
33
|
+
});
|
|
34
|
+
var phaseShiftCostSchema = z.object({
|
|
35
|
+
amountUsd: z.number().nonnegative(),
|
|
36
|
+
amountBasis: z.enum(["measured", "list-estimate", "unknown"]),
|
|
37
|
+
inputTokens: z.number().int().nonnegative(),
|
|
38
|
+
outputTokens: z.number().int().nonnegative(),
|
|
39
|
+
/**
|
|
40
|
+
* The highest provider-attempt ordinal this check accounted for; recovery
|
|
41
|
+
* after a failure makes it more than one. An attempt that threw before
|
|
42
|
+
* reporting usage is invisible to accounting, so this is a lower bound on
|
|
43
|
+
* what the provider was actually asked to do.
|
|
44
|
+
*/
|
|
45
|
+
attempts: z.number().int().positive()
|
|
46
|
+
});
|
|
47
|
+
var observationFields = {
|
|
48
|
+
detectorVersion: z.literal(PHASE_SHIFT_DETECTOR_VERSION),
|
|
49
|
+
modelReference: z.string().min(1).max(256),
|
|
50
|
+
observedAt: z.iso.datetime(),
|
|
51
|
+
throughEntryId: entryId,
|
|
52
|
+
windowEntryIds: z.array(entryId).min(1).max(40),
|
|
53
|
+
memory: phaseShiftMemorySchema
|
|
54
|
+
};
|
|
55
|
+
var changeFields = {
|
|
56
|
+
startEntryId: entryId,
|
|
57
|
+
relation: z.enum(["outcome", "phase"]),
|
|
58
|
+
reason: z.string().trim().min(1).max(2e3),
|
|
59
|
+
purposeAfter: purpose
|
|
60
|
+
};
|
|
61
|
+
var phaseShiftObservationV1Schema = z.object({
|
|
62
|
+
version: z.literal(1),
|
|
63
|
+
...observationFields,
|
|
64
|
+
changes: z.array(z.object(changeFields)).max(39)
|
|
65
|
+
});
|
|
66
|
+
var phaseShiftObservationV2Schema = z.object({
|
|
67
|
+
version: z.literal(2),
|
|
68
|
+
...observationFields,
|
|
69
|
+
/** Timestamp of the `windowEntryIds[0]` entry: where this check's coverage starts. */
|
|
70
|
+
windowStartAt: sourceTime,
|
|
71
|
+
/** Timestamp of the `throughEntryId` entry: where this check's coverage ends. */
|
|
72
|
+
throughAt: sourceTime,
|
|
73
|
+
/** What this check cost to run; absent on a record written before it was accounted. */
|
|
74
|
+
cost: phaseShiftCostSchema.optional(),
|
|
75
|
+
changes: z.array(z.object({
|
|
76
|
+
...changeFields,
|
|
77
|
+
/** Timestamp of the `startEntryId` entry: when the new work began. */
|
|
78
|
+
startedAt: sourceTime
|
|
79
|
+
})).max(39)
|
|
80
|
+
});
|
|
81
|
+
var phaseShiftObservationSchema = z.discriminatedUnion("version", [
|
|
82
|
+
phaseShiftObservationV1Schema,
|
|
83
|
+
phaseShiftObservationV2Schema
|
|
84
|
+
]);
|
|
85
|
+
function acceptedPhaseShiftObservationEntries(branch, options) {
|
|
86
|
+
const positions = new Map(branch.flatMap((entry, index) => entry.type === "message" || entry.type === "custom_message" ? [[entry.id, index]] : []));
|
|
87
|
+
const accepted = [];
|
|
88
|
+
let through = -1;
|
|
89
|
+
let start = -1;
|
|
90
|
+
for (let index = 0; index < branch.length; index += 1) {
|
|
91
|
+
const entry = branch[index];
|
|
92
|
+
if (entry.type !== "custom" || entry.customType !== PHASE_SHIFT_CUSTOM_TYPE)
|
|
93
|
+
continue;
|
|
94
|
+
const parsed = phaseShiftObservationSchema.safeParse(entry.data);
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
options?.onRefusedRecord?.({
|
|
97
|
+
entryId: entry.id,
|
|
98
|
+
issues: issueMarkers(parsed.error)
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const observation = parsed.data;
|
|
103
|
+
const cutoff = positions.get(observation.throughEntryId) ?? -1;
|
|
104
|
+
const memoryStart = positions.get(observation.memory.startEntryId) ?? -1;
|
|
105
|
+
const window = observation.windowEntryIds.map((id) => positions.get(id) ?? -1);
|
|
106
|
+
const onsets = observation.changes.map((change) => positions.get(change.startEntryId) ?? -1);
|
|
107
|
+
if (cutoff <= through || cutoff >= index || memoryStart < 0 || memoryStart < start || memoryStart > cutoff || window.at(-1) !== cutoff || window.some((position, i) => position < 0 || i > 0 && position <= window[i - 1]) || onsets.some((position, i) => !window.includes(position) || position <= window[0] || position <= start || i > 0 && position <= onsets[i - 1]) || observation.memory.anchors.some((anchor) => {
|
|
108
|
+
const position = positions.get(anchor.entryId) ?? -1;
|
|
109
|
+
return position < memoryStart || position > cutoff;
|
|
110
|
+
}))
|
|
111
|
+
continue;
|
|
112
|
+
const lastChange = observation.changes.at(-1);
|
|
113
|
+
if (lastChange !== void 0) {
|
|
114
|
+
if (observation.memory.startEntryId !== lastChange.startEntryId || observation.memory.purpose !== lastChange.purposeAfter)
|
|
115
|
+
continue;
|
|
116
|
+
} else {
|
|
117
|
+
const previous = accepted.at(-1)?.observation;
|
|
118
|
+
if (previous !== void 0 && JSON.stringify(observation.memory) !== JSON.stringify(previous.memory)) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
accepted.push({ entryId: entry.id, observation });
|
|
123
|
+
through = cutoff;
|
|
124
|
+
start = memoryStart;
|
|
125
|
+
}
|
|
126
|
+
return accepted;
|
|
127
|
+
}
|
|
128
|
+
function acceptedPhaseShiftObservations(branch, options) {
|
|
129
|
+
return acceptedPhaseShiftObservationEntries(branch, options).map((entry) => entry.observation);
|
|
130
|
+
}
|
|
131
|
+
function issueMarkers(error) {
|
|
132
|
+
return error.issues.slice(0, 3).map((issue) => {
|
|
133
|
+
const record = issue;
|
|
134
|
+
const code = typeof record.code === "string" ? record.code : "issue";
|
|
135
|
+
const path = Array.isArray(record.path) ? record.path.join(".") : "";
|
|
136
|
+
return `${code}@${path}`;
|
|
137
|
+
}).join(";").slice(0, 120);
|
|
138
|
+
}
|
|
139
|
+
function phaseShiftTimelineRecord(observation) {
|
|
140
|
+
return {
|
|
141
|
+
version: 1,
|
|
142
|
+
detectorVersion: observation.detectorVersion,
|
|
143
|
+
modelReference: observation.modelReference,
|
|
144
|
+
observedAt: observation.observedAt,
|
|
145
|
+
windowStartEntryId: observation.windowEntryIds[0],
|
|
146
|
+
windowStartAt: observation.windowStartAt,
|
|
147
|
+
throughEntryId: observation.throughEntryId,
|
|
148
|
+
throughAt: observation.throughAt,
|
|
149
|
+
purpose: observation.memory.purpose,
|
|
150
|
+
...observation.cost === void 0 ? {} : { cost: observation.cost },
|
|
151
|
+
changes: observation.changes.map((change) => ({
|
|
152
|
+
startEntryId: change.startEntryId,
|
|
153
|
+
startedAt: change.startedAt,
|
|
154
|
+
relation: change.relation,
|
|
155
|
+
reason: change.reason,
|
|
156
|
+
purposeAfter: change.purposeAfter
|
|
157
|
+
}))
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function phaseShiftTimelineFromEntries(entries) {
|
|
161
|
+
const onBranch = branchEntryIds(entries);
|
|
162
|
+
const branch = entries.filter((entry) => onBranch.has(entry.id));
|
|
163
|
+
const timestamps = new Map(branch.map((entry) => [entry.id, entry.timestamp]));
|
|
164
|
+
const at = (id) => timestamps.get(id) ?? "";
|
|
165
|
+
return acceptedPhaseShiftObservations(branch).map((observation) => observation.version === 2 ? phaseShiftTimelineRecord(observation) : phaseShiftTimelineRecord({
|
|
166
|
+
...observation,
|
|
167
|
+
version: 2,
|
|
168
|
+
windowStartAt: at(observation.windowEntryIds[0]),
|
|
169
|
+
throughAt: at(observation.throughEntryId),
|
|
170
|
+
changes: observation.changes.map((change) => ({
|
|
171
|
+
...change,
|
|
172
|
+
startedAt: at(change.startEntryId)
|
|
173
|
+
}))
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
export {
|
|
177
|
+
PHASE_SHIFT_CUSTOM_TYPE,
|
|
178
|
+
PHASE_SHIFT_TIMELINE_CUSTOM_TYPE,
|
|
179
|
+
phaseShiftTimelineFromEntries
|
|
180
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ids on a session's active branch: the leaf chain walked parent-ward from
|
|
3
|
+
* the tip, which a trailing `leaf` marker moves. Empty for an empty log.
|
|
4
|
+
*
|
|
5
|
+
* One walk shared by every reader of "is this entry still live", so a rewind
|
|
6
|
+
* means the same thing to every projection that calls it. Self-guarding
|
|
7
|
+
* against a cyclic or otherwise malformed parent chain: the loop stops the
|
|
8
|
+
* moment it revisits an id, so a cycle yields the cycle's own ids rather than
|
|
9
|
+
* looping forever.
|
|
10
|
+
*/
|
|
11
|
+
export declare function branchEntryIds(entries: readonly TranscriptTreeEntry[]): ReadonlySet<string>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every `type: "custom"` entry of one `customType`, oldest→newest, with every
|
|
15
|
+
* field ({@link TranscriptSourceEntry}'s id/timestamp/tree links plus the
|
|
16
|
+
* entry's own `data`) preserved unchanged — `data` is returned exactly as it
|
|
17
|
+
* arrived, opaque and unvalidated, never coerced toward a schema this module
|
|
18
|
+
* cannot know.
|
|
19
|
+
*
|
|
20
|
+
* `options.branch` is required, deliberately: `"active"` filters to
|
|
21
|
+
* {@link branchEntryIds}; `"all"` returns every matching entry in the flat
|
|
22
|
+
* log, rewind-abandoned or not. A silent default would let a caller reach
|
|
23
|
+
* for whichever one it did not mean to — pick `"all"` only when the caller
|
|
24
|
+
* itself needs to reason about abandoned entries (an audit, a recovery
|
|
25
|
+
* tool); an ordinary reader states `"active"` explicitly.
|
|
26
|
+
*/
|
|
27
|
+
export declare function customEntriesFromEntries<T extends TranscriptSourceEntry = TranscriptSourceEntry>(entries: readonly T[], customType: string, options: {
|
|
28
|
+
readonly branch: "active" | "all";
|
|
29
|
+
}): readonly T[];
|
|
30
|
+
|
|
31
|
+
/** The fields the customType selector needs off a session entry. */
|
|
32
|
+
export declare interface TranscriptEntry {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly type: string;
|
|
35
|
+
readonly customType?: string;
|
|
36
|
+
/** Opaque. Never parsed or validated here — a feature's own schema owns that. */
|
|
37
|
+
readonly data?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A tree entry plus the ISO timestamp the timeline projections read alongside
|
|
42
|
+
* the walk result (`turnSegments`, `phaseShiftTimelineFromEntries`).
|
|
43
|
+
*/
|
|
44
|
+
export declare interface TranscriptSourceEntry extends TranscriptTreeEntry {
|
|
45
|
+
readonly timestamp: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A session entry as {@link branchEntryIds} reads it: the fields above plus the
|
|
50
|
+
* tree links the walk follows to find the active branch. No timestamp: the walk
|
|
51
|
+
* never reads one, and a live v2 entry carries epoch milliseconds where a raw
|
|
52
|
+
* v3 log carries an ISO string, so requiring either shape here would force one
|
|
53
|
+
* class of caller to convert entries it only wants walked.
|
|
54
|
+
*/
|
|
55
|
+
declare interface TranscriptTreeEntry extends TranscriptEntry {
|
|
56
|
+
readonly parentId?: string | null;
|
|
57
|
+
readonly targetId?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// packages/plugin-protocol/dist/transcript.js
|
|
2
|
+
function branchEntryIds(entries) {
|
|
3
|
+
const last = entries[entries.length - 1];
|
|
4
|
+
if (last === void 0)
|
|
5
|
+
return EMPTY_BRANCH_IDS;
|
|
6
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
7
|
+
const onBranch = /* @__PURE__ */ new Set();
|
|
8
|
+
let cursor = last.type === "leaf" ? last.targetId ?? null : last.id;
|
|
9
|
+
while (cursor !== null && !onBranch.has(cursor)) {
|
|
10
|
+
onBranch.add(cursor);
|
|
11
|
+
cursor = byId.get(cursor)?.parentId ?? null;
|
|
12
|
+
}
|
|
13
|
+
return onBranch;
|
|
14
|
+
}
|
|
15
|
+
var EMPTY_BRANCH_IDS = /* @__PURE__ */ new Set();
|
|
16
|
+
function customEntriesFromEntries(entries, customType, options) {
|
|
17
|
+
const onBranch = options.branch === "all" ? null : branchEntryIds(entries);
|
|
18
|
+
return entries.filter((entry) => entry.type === "custom" && entry.customType === customType && (onBranch === null || onBranch.has(entry.id)));
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
branchEntryIds,
|
|
22
|
+
customEntriesFromEntries
|
|
23
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yaklabs-ai/plugin-sdk",
|
|
3
|
+
"version": "0.8.4",
|
|
4
|
+
"description": "The Kay plugin SDK — types and helpers for authoring a Kay directory plugin.",
|
|
5
|
+
"author": "YakLabs",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"exports": {
|
|
12
|
+
"./kay-agent": {
|
|
13
|
+
"types": "./dist/kay-agent.d.ts",
|
|
14
|
+
"default": "./dist/kay-agent.js"
|
|
15
|
+
},
|
|
16
|
+
"./kay-app": {
|
|
17
|
+
"types": "./dist/kay-app.d.ts",
|
|
18
|
+
"default": "./dist/kay-app.js"
|
|
19
|
+
},
|
|
20
|
+
"./kay-agent/testing": {
|
|
21
|
+
"types": "./dist/kay-agent-testing.d.ts",
|
|
22
|
+
"default": "./dist/kay-agent-testing.js"
|
|
23
|
+
},
|
|
24
|
+
"./disclosure": {
|
|
25
|
+
"types": "./dist/disclosure.d.ts",
|
|
26
|
+
"default": "./dist/disclosure.js"
|
|
27
|
+
},
|
|
28
|
+
"./phase-shift": {
|
|
29
|
+
"types": "./dist/phase-shift.d.ts",
|
|
30
|
+
"default": "./dist/phase-shift.js"
|
|
31
|
+
},
|
|
32
|
+
"./transcript": {
|
|
33
|
+
"types": "./dist/transcript.d.ts",
|
|
34
|
+
"default": "./dist/transcript.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"THIRD-PARTY-NOTICES.md",
|
|
42
|
+
"THIRD-PARTY-INVENTORY.json",
|
|
43
|
+
"skills"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"zod": "^4.0.0",
|
|
47
|
+
"@typesafe-ai/sdk": "0.6.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"registry": "https://registry.npmjs.org",
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/yaklabs-ai/kay.git",
|
|
56
|
+
"directory": "packages/plugin-sdk"
|
|
57
|
+
},
|
|
58
|
+
"license": "MIT"
|
|
59
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: directory-plugin-authoring
|
|
3
|
+
description: "Author, scaffold, debug, or iterate on a Kay directory plugin (one the user writes in ~/.kay/plugins/<name>). Use when creating a new local plugin, when a plugin does not appear in Kay after installation, when enabling it fails with 'pluginActivation names a plugin that is not installed', when Settings → Plugins shows 'failed to load' with no toggle, when @kay/plugin-sdk imports fail to resolve, when a plugin declares a ui.tsx that nothing typechecks, or when changes to a plugin do not take effect. For installing plugins from a git repository source, use the manage-plugins skill instead; for first-party bundled plugins under plugins/ in the kay repo, use the bundled-plugin guidance there."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Directory plugin authoring
|
|
7
|
+
|
|
8
|
+
A directory plugin is a plugin the user authors in `~/.kay/plugins/<name>/`, outside the Kay
|
|
9
|
+
repo. The full authoring guide is [`docs/directory-plugins.md`](../../docs/directory-plugins.md)
|
|
10
|
+
and the SDK reference is `packages/plugin-sdk/README.md`; this skill is the routing layer that
|
|
11
|
+
keeps an agent on the paved path and out of the known pits.
|
|
12
|
+
|
|
13
|
+
## Confirm the checkout is current before you scaffold
|
|
14
|
+
|
|
15
|
+
`pnpm create-plugin` writes absolute type paths (`tsconfig.json`, `package.json`) pointing at
|
|
16
|
+
whichever Kay checkout invoked it, with no staleness warning. A checkout far behind `origin/main`
|
|
17
|
+
— one was 752 commits behind — typechecks the new plugin against stale `@kay/plugin-sdk` types,
|
|
18
|
+
and it looks clean: every wrong shape it produced (a `content` object instead of
|
|
19
|
+
`run`/`openBeside` on `PaneActionDescriptor`, `SearchField`'s `onChange` instead of
|
|
20
|
+
`onValueChange`, wrong `PageState` props) typechecked fine against the old types it was
|
|
21
|
+
scaffolded from.
|
|
22
|
+
|
|
23
|
+
So preflight the checkout, before scaffolding rather than after a typecheck already looked green:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
git -C <kay-checkout> fetch origin && git -C <kay-checkout> rev-list --count HEAD..origin/main
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A nonzero count means update that checkout first, or scaffold from a fresh `origin/main` worktree
|
|
30
|
+
and rebuild `@kay/plugin-sdk` there. A green typecheck against stale types is evidence of nothing.
|
|
31
|
+
The scaffold does the second half of this for you: it prints a warning when the checkout trails
|
|
32
|
+
its last-fetched `origin/main`, and stamps the checkout's commit into the generated
|
|
33
|
+
`tsconfig.json` header. It never fetches, so the `fetch` above is still yours to run; a checkout
|
|
34
|
+
that has not fetched in a week reads as current to the scaffold.
|
|
35
|
+
|
|
36
|
+
## Scaffold, never hand-roll
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pnpm --filter @kay/plugin-sdk build # once per checkout, if not built
|
|
40
|
+
pnpm create-plugin --id com.example.widgets
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run from the root of a Kay checkout. It writes a working plugin (manifest, entrypoint, tsconfig,
|
|
44
|
+
README) that already typechecks and loads; `--tier starter` adds a URI module, a bundled skill,
|
|
45
|
+
and a standalone `node --test` test to copy from. Add `--ui` (either tier) when the plugin needs a
|
|
46
|
+
page: it writes `ui.tsx`, declares the `kay.ui` entrypoint, and generates the UI typecheck config
|
|
47
|
+
described below. `--ui` also needs `pnpm --filter @kay/ui build` once per checkout; the scaffold
|
|
48
|
+
stops and says so when that build is missing.
|
|
49
|
+
|
|
50
|
+
Never hand-build a plugin as a tsc-compiled package and copy the output into `~/.kay/plugins/`.
|
|
51
|
+
That path produces a plugin whose built `dist/` imports `@kay/plugin-sdk` through ordinary
|
|
52
|
+
`node_modules` resolution, which cannot work from outside the monorepo — it shipped a
|
|
53
|
+
failed-to-load plugin and a misfiled bug (#7732). The load fails with `ERR_MODULE_NOT_FOUND`,
|
|
54
|
+
and symlinking `node_modules/@kay` into the copy does not rescue it either: pnpm's `.pnpm`
|
|
55
|
+
layout does not expose workspace packages that way. The scaffold exists precisely so this
|
|
56
|
+
cannot happen.
|
|
57
|
+
|
|
58
|
+
## The SDK resolution contract
|
|
59
|
+
|
|
60
|
+
- Plugin source imports `@kay/plugin-sdk/<subpath>` as a **bare specifier**
|
|
61
|
+
(`@kay/plugin-sdk/kay-agent`, `/kay-agent/testing`, `/kay-app`, `/disclosure` — only declared
|
|
62
|
+
exports exist).
|
|
63
|
+
- The daemon's chord loader bundles each declared TypeScript entrypoint and its relative imports.
|
|
64
|
+
It keeps `@kay/plugin-sdk` imports external and resolves them to the running app's own built SDK.
|
|
65
|
+
Multi-file plugins are supported, and no plugin-local build is needed for Kay to load them.
|
|
66
|
+
- Corollary: **bare `node` outside the daemon cannot resolve `@kay/plugin-sdk`** from the plugin
|
|
67
|
+
directory unless the plugin has its own SDK dependency. That failure says nothing about whether
|
|
68
|
+
the plugin will load in Kay. The `starter` tier's test setup creates the symlink needed for its
|
|
69
|
+
standalone test; a published SDK dependency is the other standalone option.
|
|
70
|
+
- Typecheck with `npm run typecheck` inside the plugin directory; the scaffold's generated
|
|
71
|
+
tsconfig maps the SDK's types via `paths`.
|
|
72
|
+
- Chord compiles TypeScript syntax that native type stripping cannot execute, including `enum`,
|
|
73
|
+
`namespace` blocks, constructor parameter properties, and decorators. Typecheck before enabling
|
|
74
|
+
the plugin; a bundle error is reported as a target-load diagnostic.
|
|
75
|
+
|
|
76
|
+
## A UI target: three files, its own typecheck, generated
|
|
77
|
+
|
|
78
|
+
A plugin with a UI has three targets. Each declared daemon target is a bundle root, so its
|
|
79
|
+
entrypoint may import other files in the plugin; `kay.agent` runs in the daemon per session and
|
|
80
|
+
`kay.app` runs in the daemon once. `kay.ui` runs in the renderer through its own UI bundle, which is a single
|
|
81
|
+
self-contained module: unlike the daemon targets, `ui.tsx` cannot import sibling files. A
|
|
82
|
+
plugin UI contributes through `api.slots` (`registerPage`, `registerSidebarItem`,
|
|
83
|
+
`registerPaneAction`, and the rest of `buildSlots` in
|
|
84
|
+
`apps/kay/src/renderer/src/extension-loader/build-extension-api.ts`). A page gets its own
|
|
85
|
+
activity-rail button when a `registerSidebarItem({ pageId, placement: "activityRail" })` from the
|
|
86
|
+
same plugin points at it.
|
|
87
|
+
|
|
88
|
+
**Icons: declare one plugin icon, override only where you mean it.** Set the manifest `icon` (a
|
|
89
|
+
package-relative SVG) and every surface the plugin contributes inherits it: reference pills, its
|
|
90
|
+
rail button, and its pages' tabs, pane headers and palette entries (shown as your full-colour mark
|
|
91
|
+
on a small white tile). Set `icon` on a `registerSidebarItem` or `registerPage` descriptor only to
|
|
92
|
+
show a monochrome glyph there instead (a glyph name such as `"git-branch"`, or `{ svg }`); a page
|
|
93
|
+
and its rail item borrow each other's glyph, so one declaration covers the destination. Full
|
|
94
|
+
precedence: `packages/kay-frontend-sdk/ARCHITECTURE.md`, "Plugin icon".
|
|
95
|
+
|
|
96
|
+
`ui.tsx` has no `node_modules` of its own. The renderer bundles it and rewrites its `react` and
|
|
97
|
+
`@kay/*` imports to the host's `__KAY_UI_RUNTIME__` singletons, so the agent `tsconfig.json`
|
|
98
|
+
cannot cover it, and a plugin whose UI nothing typechecks has shipped three wrong API shapes at
|
|
99
|
+
once. `pnpm create-plugin --ui` generates `tsconfig.ui.json` for exactly this: its `paths` are
|
|
100
|
+
derived from the bundler's own shim set (`uiRuntimeShimModules` in `@kay/ui-app-bundler`), each
|
|
101
|
+
specifier mapped to the type entry the renderer binds it to, and `npm run typecheck` runs both
|
|
102
|
+
configs. Do not hand-write the map; when the bundler's set changes, the scaffold's test fails and
|
|
103
|
+
the generated config follows.
|
|
104
|
+
|
|
105
|
+
Two facts about that config worth knowing when you read errors from it:
|
|
106
|
+
|
|
107
|
+
- The bare `@kay/frontend-sdk` is typed against the SDK's `ui-runtime` facade, because that is
|
|
108
|
+
what the renderer binds it to. The facade carries the SDK's whole type surface, so
|
|
109
|
+
`KayUiExtensionApi` and every descriptor type import fine; a **value** only the package's main
|
|
110
|
+
entry exports fails to compile there (TS1362, "exported using export type"), which is the
|
|
111
|
+
compiler telling you the running extension would receive `undefined`.
|
|
112
|
+
- It typechecks `ui.tsx` alone, as a browser module: `Bundler` resolution, automatic JSX, DOM
|
|
113
|
+
libs, no Node globals. The agent config keeps `**/*.ts`, so the two programs never overlap.
|
|
114
|
+
|
|
115
|
+
For a plugin scaffolded before `--ui` existed, scaffold a throwaway `--ui` plugin from the same
|
|
116
|
+
checkout and copy its `tsconfig.ui.json` and the second `typecheck` command into yours; `--force`
|
|
117
|
+
rewrites every generated file, including `entrypoint.ts`, so do not point it at a plugin you have
|
|
118
|
+
edited.
|
|
119
|
+
|
|
120
|
+
## The iteration loop
|
|
121
|
+
|
|
122
|
+
1. **Write the directory under the local plugin root, and back it with git.** A local plugin
|
|
123
|
+
IS a git repository: a version is a commit, and a directory without git backing fails to
|
|
124
|
+
load (the plugin's row says exactly that, with the fix). So after writing the files, commit
|
|
125
|
+
them: `git init && git add -A && git commit -m "adopt plugin"` inside the plugin directory.
|
|
126
|
+
The daemon watches the root, so the plugin appears by itself within a couple of seconds, no
|
|
127
|
+
verb called and no restart; the source refresh verb forces the walk if you want it now.
|
|
128
|
+
|
|
129
|
+
The root is `<home>/.kay/plugins`, and **`<home>` is the running instance's home**: a dev
|
|
130
|
+
app launched against its own state root has its own plugins root, not your real
|
|
131
|
+
`~/.kay/plugins`. Ask the daemon you are actually talking to rather than assuming — `sources
|
|
132
|
+
list` names the exact local root — because writing into the wrong one produces a plugin that
|
|
133
|
+
never appears and no diagnostic anywhere.
|
|
134
|
+
|
|
135
|
+
2. **Enable it once.** Directory plugins are disabled on arrival (fail-closed: writing files in
|
|
136
|
+
your own plugin folder is how Kay DISCOVERS it, not consent to run it). Flip the toggle in
|
|
137
|
+
Settings → Plugins → your plugin's detail pane, or ask Kay to write the `installs` row's
|
|
138
|
+
`enabled` field. Enabling reconciles live, and contributions reach the agent's **next**
|
|
139
|
+
session, not the current one.
|
|
140
|
+
3. **Edit, then commit.** `git add -A && git commit` makes the new code available.
|
|
141
|
+
Kay loads an isolated snapshot of HEAD and activates it live; no push or restart
|
|
142
|
+
is needed. Uncommitted edits, staged additions, deleted working files and runtime
|
|
143
|
+
writes do not execute, including at cold boot. The detail pane's **Running
|
|
144
|
+
commit** identifies the serving snapshot. Try new tools in a fresh session;
|
|
145
|
+
an existing session may still hold its previous generation.
|
|
146
|
+
|
|
147
|
+
A failed committed update keeps the last working revision and shows the load
|
|
148
|
+
diagnostic. Fix and commit the repair. Explicit source refresh retries discovery
|
|
149
|
+
or a failed read; it never activates uncommitted files. Keep runtime data in
|
|
150
|
+
host-owned plugin storage, not beside the snapshot's source files.
|
|
151
|
+
|
|
152
|
+
4. **Grant what the manifest asks for, or activation is refused.** Anything declared under
|
|
153
|
+
`contributes` that carries a capability — a `contextContributors` entry needs
|
|
154
|
+
`dynamic-context.register` for that id, a data source needs `app-data.register`, a foreign
|
|
155
|
+
config section needs `config.declare-section` — must ALSO be granted before the plugin can
|
|
156
|
+
use it. Until then `activate()` throws at the first call, the plugin reads
|
|
157
|
+
`activation-failed`, its row carries a `grant` need naming the exact claim, and the page
|
|
158
|
+
shows the refusal text. The answer is the grant verb with that same claim shape (target,
|
|
159
|
+
slug, envelope), after which the activation is retried for you:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"target": "kay.app",
|
|
164
|
+
"slug": "dynamic-context.register",
|
|
165
|
+
"envelope": { "contributor": "<your-contributor-id>" }
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Granting is the human-approval gate, so an agent writing the row's `grants` raises a diff for
|
|
170
|
+
the user to accept; enabling and disabling do not.
|
|
171
|
+
|
|
172
|
+
5. **Iterate in a fresh session.** A typecheck or direct tool call cannot show whether an agent
|
|
173
|
+
can discover and use the surface; try the user-goal case in a real new session.
|
|
174
|
+
|
|
175
|
+
## Before you commit, verify what the commit will activate
|
|
176
|
+
|
|
177
|
+
The commit is the activation boundary, so run these against the working tree first. Each catches
|
|
178
|
+
a failure class the load would otherwise be the first thing to surface:
|
|
179
|
+
|
|
180
|
+
1. **Validate `kay-plugin.json` against `KayPluginManifestV1`** (from `@kay/plugin-protocol`),
|
|
181
|
+
not just "it is valid JSON" — that schema is the gate the scan actually runs. Check every
|
|
182
|
+
declared target's endpoints are namespaced correctly.
|
|
183
|
+
2. **Run the disclosure audit yourself**: `auditPluginDisclosure` from `@kay/plugin-sdk/disclosure`.
|
|
184
|
+
It is not redundant with typecheck. A plugin shipped a `search` verb with no `verbDocs` entry
|
|
185
|
+
that typechecked clean and would have failed the whole `kay.agent` target.
|
|
186
|
+
3. **Typecheck every target**: `npm run typecheck` runs the agent config and, for a `--ui`
|
|
187
|
+
scaffold, `tsconfig.ui.json` after it. A plugin with a `ui.tsx` and only one config has an
|
|
188
|
+
unchecked target.
|
|
189
|
+
4. **Round-trip the targets against each other.** They share no imports, so a typecheck proves
|
|
190
|
+
the shapes compile, not that `kay.app` and `kay.agent` agree on the data they share: write
|
|
191
|
+
through the UI's daemon endpoint, read it back through the agent's `scheme://` module, then
|
|
192
|
+
the reverse.
|
|
193
|
+
|
|
194
|
+
Then commit — that is what activates it — and confirm the serving snapshot is the commit you
|
|
195
|
+
just made: `read plugin://<id>` for the expected tools and skills with no load diagnostic, and
|
|
196
|
+
the detail pane's **Running commit** row for the revision.
|
|
197
|
+
|
|
198
|
+
One thing no commit proves: whether the UI entrypoint bundles in a **packaged** build. esbuild's
|
|
199
|
+
native binary resolution can fail silently inside `app.asar` (`ui_entrypoint_bundle_failed`), and
|
|
200
|
+
the only trace is `ui-app.directory-load-failed` in the day's `kay-<date>.jsonl` log —
|
|
201
|
+
`plugin://<id>` still reads enabled with no failure noted, and the pane simply has no overflow
|
|
202
|
+
menu. Current builds carry the fix (#8014). A dev app runs unpackaged and never exercises that
|
|
203
|
+
path, so report the packaged case as unverified instead of assuming it either way.
|
|
204
|
+
|
|
205
|
+
Dev-instance warning: a worktree dev app (`pnpm dev:kay`) is a **separate daemon** from the
|
|
206
|
+
user's installed Kay, with its own config store, consent records, and plugin roster. A plugin
|
|
207
|
+
visible in one is invisible to the other, and a `config://settings/pluginActivation` write from
|
|
208
|
+
a session in one daemon validates against that daemon's roster only. #7732 was two daemons'
|
|
209
|
+
outputs conflated into one impossible-looking bug; always ask "which daemon am I looking at?"
|
|
210
|
+
|
|
211
|
+
## When it does not load
|
|
212
|
+
|
|
213
|
+
Read the Plugins page before the logs; every rejection stage produces a named diagnostic shown
|
|
214
|
+
on the plugin's row (or on the plugin source's row, when the whole source found nothing).
|
|
215
|
+
|
|
216
|
+
- **"failed to load" with no toggle** is the synthesized row for an install that failed before
|
|
217
|
+
producing a working plugin — it is untogglable by design. Fix the load failure; the toggle
|
|
218
|
+
belongs to the healthy row.
|
|
219
|
+
- **"names a plugin that is not installed"** means exactly that in this daemon: the plugin is
|
|
220
|
+
not in the composed roster. Check a scan actually saw it (`plugin-directory-scan.finished`
|
|
221
|
+
logs on every walk, not only at boot — an unconsented root skips the scan entirely), check
|
|
222
|
+
the root you wrote into is the one `sources list` names for THIS daemon, then check you are
|
|
223
|
+
talking to the daemon you think you are.
|
|
224
|
+
- **A plugin that reads `activation-failed` has usually been refused a capability**, not broken
|
|
225
|
+
its code: read the reason on its row (the failed target carries the refusal text) and grant
|
|
226
|
+
the claim it names.
|
|
227
|
+
- **A symlinked plugin directory may be skipped by the scan without a diagnostic.** If the scan
|
|
228
|
+
accepts N-1 of your N directories with zero diagnostics, look for a symlink.
|
|
229
|
+
- **The disclosure audit is fail-closed** and runs on every directory plugin; a violation fails
|
|
230
|
+
the whole `kay.agent` target. Run `auditPluginDisclosure` yourself before committing, per the
|
|
231
|
+
pre-commit checks above.
|
|
232
|
+
|
|
233
|
+
## Declaring a scheduled workflow
|
|
234
|
+
|
|
235
|
+
A directory plugin does not request workflows in its manifest. During `kay.app` activation, call
|
|
236
|
+
`app.workflows.define(localId, { name, trigger, run })` to put a durable, daemon-scheduled row on
|
|
237
|
+
the Automations page. The host raises the `workflows.define` claim for every `kay.app` plugin; a
|
|
238
|
+
bundled install holds every grant, while a directory plugin needs the user to add that grant to its
|
|
239
|
+
`installs` row. Source class no longer decides workflow authority, and the capability is ABSENT
|
|
240
|
+
rather than refused when ungranted, so guard on `app.workflows === undefined`.
|
|
241
|
+
|
|
242
|
+
The product-agent procedure is the builtin skill `create-workflow`
|
|
243
|
+
(`packages/agent-runtime/src/kay/builtin-skills/`), and the API reference is the "Durable Workflows
|
|
244
|
+
In A Plugin" section of `apps/kay/agent-docs/extensions-api.md`. Keep those two in step with this
|
|
245
|
+
file.
|
|
246
|
+
|
|
247
|
+
## Reference map
|
|
248
|
+
|
|
249
|
+
- [`docs/directory-plugins.md`](../../docs/directory-plugins.md) — the authoring guide:
|
|
250
|
+
manifest shape, the enable gate and what it does not gate, trust tiers, diagnostics.
|
|
251
|
+
- `packages/plugin-sdk/README.md` — the SDK surface.
|
|
252
|
+
- `packages/plugin-sdk/PUBLISHED.md` — installing the published SDK outside a Kay checkout.
|