@yaklabs-ai/plugin-sdk 0.13.0 → 0.13.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/dist/kay-app.js CHANGED
@@ -105,6 +105,9 @@ var RUN_ID = /^\P{Cc}+$/u;
105
105
  var POINTER_RULE = `1 to ${MAX_POINTER} characters with no whitespace, \`/\`, \`?\`, \`#\`, or control or invisible character`;
106
106
  var ObserveShape = z.looseObject({
107
107
  detector: z.looseObject({ id: z.string(), version: z.string() }),
108
+ subject: z.looseObject({ kind: z.string(), key: z.string().optional() }).optional(),
109
+ after: z.looseObject({ observationId: z.unknown() }).optional(),
110
+ reuse: z.looseObject({ maxAgeMs: z.unknown() }).optional(),
108
111
  source: z.looseObject({
109
112
  sessionId: z.string(),
110
113
  entryId: z.string(),
@@ -119,6 +122,7 @@ var ChoiceQuestion = z.looseObject({
119
122
  criteria: z.record(z.string(), z.unknown())
120
123
  });
121
124
  var fits = (value, grammar) => value.length <= MAX_NAME && grammar.test(value);
125
+ var isWhole = (value, min) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= SIGNAL_TIME_MAX_MS;
122
126
  var quoted = (value) => JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}\u2026` : value).replace(/(?! )[\s\p{C}]/gu, (char) => `\\u${char.codePointAt(0).toString(16).padStart(4, "0")}`);
123
127
  function grammarProblem(input) {
124
128
  const shape = ObserveShape.safeParse(input);
@@ -126,7 +130,7 @@ function grammarProblem(input) {
126
130
  const field = shape.error.issues[0]?.path.join(".") ?? "";
127
131
  return field === "" ? "`observe` takes an object with `detector`, `source`, `state`, and `questions`." : `\`${field}\` is missing or has the wrong type.`;
128
132
  }
129
- const { detector, source, questions } = shape.data;
133
+ const { detector, source, questions, subject, after, reuse } = shape.data;
130
134
  const { state } = input;
131
135
  if (typeof state !== "object" || state === null || Array.isArray(state)) {
132
136
  return "`state` must be a JSON object.";
@@ -156,6 +160,23 @@ function grammarProblem(input) {
156
160
  if (occurredAtMs !== void 0 && !(typeof occurredAtMs === "number" && Number.isSafeInteger(occurredAtMs) && occurredAtMs >= 0 && occurredAtMs <= SIGNAL_TIME_MAX_MS)) {
157
161
  return `\`source.occurredAtMs\` must be whole epoch milliseconds from 0 to ${SIGNAL_TIME_MAX_MS}.`;
158
162
  }
163
+ if (subject !== void 0 && !fits(subject.kind, DETECTOR_ID)) {
164
+ return "`subject.kind` must be a lowercase slug of at most 64 characters: a-z and 0-9 in words joined by single hyphens.";
165
+ }
166
+ if (subject?.key !== void 0 && !(subject.key.length <= MAX_POINTER && POINTER.test(subject.key))) {
167
+ return `\`subject.key\` must be ${POINTER_RULE}.`;
168
+ }
169
+ if (after !== void 0 && !isWhole(after.observationId, 1)) {
170
+ return "`after.observationId` must be a positive whole number: the id of a stored signal.";
171
+ }
172
+ if (reuse !== void 0) {
173
+ if (!isWhole(reuse.maxAgeMs, 1)) {
174
+ return `\`reuse.maxAgeMs\` must be whole milliseconds from 1 to ${SIGNAL_TIME_MAX_MS}.`;
175
+ }
176
+ if (subject?.key === void 0) {
177
+ return "`reuse` needs `subject.key`: a stored answer is reused only for the same subject.";
178
+ }
179
+ }
159
180
  const names = Object.keys(questions);
160
181
  if (names.length === 0 || names.length > MAX_QUESTIONS) {
161
182
  return `\`questions\` must ask 1 to ${MAX_QUESTIONS} questions.`;
package/dist/signals.d.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  */
8
8
  export declare function createFallbackOnlySignals(): KayAppSignals;
9
9
 
10
+ /** The engine over one plugin's `observe` (normally `app.signals.observe`). */
11
+ export declare function createSignalEngine(observe: KayAppSignals["observe"]): SignalEngine;
12
+
10
13
  /**
11
14
  * The `invalid` observation `observe` answers when a name in `input` breaks the recorded-name
12
15
  * grammar, or `undefined` when every name can be recorded. A reader later types these names into
@@ -86,7 +89,7 @@ declare interface JevScoreQuestion {
86
89
  * Those are host-side (`@kay/agent-runtime`'s `kay/jev/`).
87
90
  * ---
88
91
  *
89
- * The vocabulary is Kay's own (SDK 0.12.0). Before it, `use` received TypeSafe's `TypeSafeClient`
92
+ * The vocabulary is Bonsai's own (SDK 0.12.0). Before it, `use` received TypeSafe's `TypeSafeClient`
90
93
  * and plugins coded against a vendor's types. It follows pi's classifier contract, which the host
91
94
  * runs Jev through: a yes/no question is `bool` and answers a `probability`, every instruction and
92
95
  * criterion is text, and the state is a JSON object.
@@ -106,6 +109,10 @@ export declare interface KayAppSignal {
106
109
  readonly pluginId: string;
107
110
  };
108
111
  readonly source: KayAppSignalSource;
112
+ /** What the signal is about, when its recorder named a subject. */
113
+ readonly subject?: KayAppSignalSubject;
114
+ /** The id of the upstream signal this one was chained after, while that row exists. */
115
+ readonly upstreamId?: number;
109
116
  readonly recordedAtMs: number;
110
117
  /** Keyed by question name, full probability distributions included. */
111
118
  readonly answers: Readonly<Record<string, KayAppSignalAnswer>>;
@@ -165,12 +172,20 @@ export declare interface KayAppSignalNoulAnswer {
165
172
  *
166
173
  * A repeat call over the same (plugin, detector, version, session, entry, input digest) returns
167
174
  * the ALREADY-STORED row with `deduplicated: true` and never calls Jev again: replay is free.
175
+ * A call with `reuse` that finds a recent row for the same subject key returns that row with
176
+ * `deduplicated: true` and `reused: true`; its `source` is where the row was computed.
168
177
  */
169
178
  export declare interface KayAppSignalObservation {
170
179
  readonly id?: number | undefined;
171
180
  readonly status: "classified" | "unavailable" | "invalid";
172
181
  readonly detector: KayAppSignalsDetectorRef;
173
182
  readonly source: KayAppSignalSource;
183
+ /** The row's subject, when it was recorded with one. */
184
+ readonly subject?: KayAppSignalSubject | undefined;
185
+ /** The row's upstream link: the id of the row it was chained after. */
186
+ readonly upstreamId?: number | undefined;
187
+ /** True when the answer is a stored row found through `reuse`, not this call's own. */
188
+ readonly reused?: boolean | undefined;
174
189
  readonly answers?: Record<string, unknown> | undefined;
175
190
  readonly model?: string | undefined;
176
191
  readonly usage?: {
@@ -203,6 +218,18 @@ export declare interface KayAppSignalPage {
203
218
  readonly nextCursor?: string;
204
219
  }
205
220
 
221
+ /**
222
+ * Reuse a stored answer for the same subject instead of asking Jev again. Before any Jev call
223
+ * the host looks for the newest row this plugin recorded with the same detector, version,
224
+ * subject key and input digest, recorded at most `maxAgeMs` ago, in ANY session. When one
225
+ * exists, that row is the answer: no Jev call and no new row. The returned observation keeps the
226
+ * row's own source (where it was computed), with `reused: true`.
227
+ */
228
+ export declare interface KayAppSignalReuse {
229
+ /** How old a stored answer may be, in ms: a whole number from 1 to {@link SIGNAL_TIME_MAX_MS}. */
230
+ readonly maxAgeMs: number;
231
+ }
232
+
206
233
  /**
207
234
  * One question of a stored rubric, as the recording plugin wrote it. Rubrics recorded before
208
235
  * SDK 0.12.0 use TypeSafe's vocabulary (`noul` for yes/no, and instructions or criteria that
@@ -331,6 +358,12 @@ export declare interface KayAppSignalsDetectorRef extends KayAppSignalsDetectorI
331
358
  export declare interface KayAppSignalsObserveInput {
332
359
  readonly detector: KayAppSignalsDetectorInput;
333
360
  readonly source: KayAppSignalSource;
361
+ /** What the signal is about. Stored on the row; not part of the dedupe key or the input digest. */
362
+ readonly subject?: KayAppSignalSubject;
363
+ /** The upstream row this observation is chained after. Stored on the row as its upstream link. */
364
+ readonly after?: KayAppSignalUpstreamLink;
365
+ /** Reuse a recent stored answer for the same subject key. Needs `subject.key`. */
366
+ readonly reuse?: KayAppSignalReuse;
334
367
  /**
335
368
  * The exact state to send, already shaped by the caller (a turn packet, a window of messages,
336
369
  * or anything else). NOT stored: the row keeps its digest, and a reader re-derives it from the
@@ -428,6 +461,34 @@ export declare interface KayAppSignalsSessionQuery {
428
461
  readonly limit?: number;
429
462
  }
430
463
 
464
+ /**
465
+ * What a signal is about, named by the recording plugin. The host stores both values and never
466
+ * interprets them. A signal recorded without a subject has none; every reader treats it as before.
467
+ */
468
+ export declare interface KayAppSignalSubject {
469
+ /**
470
+ * The kind of thing observed, e.g. `turn`: a lowercase slug of at most 64 characters, in the
471
+ * same grammar as a detector id.
472
+ */
473
+ readonly kind: string;
474
+ /**
475
+ * The subject's identity ACROSS sessions, when it has one (two sessions can observe the same
476
+ * subject). Required for {@link KayAppSignalsObserveInput.reuse}. 1 to 256 characters with no
477
+ * whitespace, `/`, `?`, `#`, or control or invisible character.
478
+ */
479
+ readonly key?: string;
480
+ }
481
+
482
+ /**
483
+ * The upstream signal a chained observation ran after: the stored row whose answers the caller
484
+ * found passing. The host checks that the row exists and was recorded by the same plugin, and
485
+ * stores the link on the new row; it never re-judges the caller's pass condition.
486
+ */
487
+ export declare interface KayAppSignalUpstreamLink {
488
+ /** The upstream row's `id`: a positive whole number. */
489
+ readonly observationId: number;
490
+ }
491
+
431
492
  /** The most labels a recorded choice question may offer, and so the most one filter names. */
432
493
  export declare const MAX_CHOICE_LABELS = 64;
433
494
 
@@ -437,6 +498,119 @@ export declare const MAX_CHOICE_LABELS = 64;
437
498
  */
438
499
  export declare const SIGNAL_TIME_MAX_MS = 8640000000000000;
439
500
 
501
+ /**
502
+ * Whether stored answers satisfy every filter, with the store's own semantics: a yes/no
503
+ * question's P(yes) (`bool`'s `probability`, or `noul` in older rows), a choice question's label
504
+ * and confidence, a score question's expected score. An answer of another kind, a missing answer
505
+ * or a malformed one never passes. No filters always pass.
506
+ */
507
+ export declare function signalAnswersPass(answers: Readonly<Record<string, unknown>> | undefined, filters: readonly KayAppSignalsAnswerFilter[]): boolean;
508
+
509
+ /**
510
+ * One Jev signal a plugin records, over subjects of type `I`. `id`, `version` and `questions`
511
+ * are exactly what `observe` records under; `subjectKind` is stored on every row.
512
+ */
513
+ export declare interface SignalDefinition<I> {
514
+ /** The detector id rows are recorded under (a lowercase slug, at most 64 characters). */
515
+ readonly id: string;
516
+ /** Bump it when a question changes meaning. */
517
+ readonly version: string;
518
+ /** What this signal is about, e.g. `turn`: a lowercase slug, stored on the row. */
519
+ readonly subjectKind: string;
520
+ readonly questions: JevQuestions;
521
+ /**
522
+ * The state Jev reads for this subject, or `undefined` when the subject carries nothing this
523
+ * signal can judge (no row is recorded). Must be a pure function of the subject: the row keeps
524
+ * only its digest.
525
+ */
526
+ readonly project: (input: I) => JevState | undefined;
527
+ /**
528
+ * The subject's identity across sessions. Required with `maxAgeMs`; without it a row is tied
529
+ * to its own anchor only.
530
+ */
531
+ readonly key?: (input: I) => string;
532
+ /**
533
+ * Reuse a stored answer for the same subject key and state for this long, in ms, in any
534
+ * session, instead of asking Jev again. Expiry belongs to the definition.
535
+ */
536
+ readonly maxAgeMs?: number;
537
+ /** The upstream signal that must pass before this one is evaluated. */
538
+ readonly after?: SignalUpstream<I>;
539
+ }
540
+
541
+ /**
542
+ * `sha256:<hex>` over a value serialised with sorted object keys, so two payloads that differ
543
+ * only in key order digest the same. The host stamps both digests a row carries with it: the
544
+ * input digest (the dedupe member of the natural key) and the rubric digest (the versioning
545
+ * tripwire). A caller may compute it too, to name a state without keeping it.
546
+ *
547
+ * The value is normalised THROUGH `JSON.stringify` first, deliberately. The digest must
548
+ * describe what the vendor actually received, and the vendor receives JSON: a `Date` goes over
549
+ * the wire as its ISO string, an `undefined` array slot as `null`, an `undefined` object value
550
+ * not at all. Canonicalising the raw JS value instead let two genuinely different states digest
551
+ * the same (`[]` and `[undefined]`; `{}` and any `Date`), and since the digest is a member of
552
+ * the store's natural key, a collision is not a crash but SILENT EVIDENCE LOSS: the second
553
+ * state dedupes against the first and is never observed. A value JSON cannot represent (a
554
+ * bigint) throws.
555
+ */
556
+ export declare function signalDigest(value: unknown): string;
557
+
558
+ export declare interface SignalEngine {
559
+ /**
560
+ * Evaluate each definition over one subject, in order, and return one result per definition.
561
+ * `source` anchors every row to the transcript; `options` is passed to every observe, with
562
+ * `purpose` set to `signals/<id>`.
563
+ */
564
+ evaluate<I>(definitions: readonly SignalDefinition<I>[], input: I, source: KayAppSignalSource, options?: WithJevOptions): Promise<readonly SignalEvaluation[]>;
565
+ }
566
+
567
+ /**
568
+ * What evaluating one definition produced:
569
+ *
570
+ * - `observed`: `observe` returned; read `observation.status` (an unavailable or invalid outcome
571
+ * is still `observed`: the engine reports what the host answered).
572
+ * - `skipped`: the projection returned `undefined`; nothing was observed.
573
+ * - `blocked`: the upstream did not pass (or the subject has none); `upstream` is its result,
574
+ * absent when `find` named no upstream subject.
575
+ * - `failed`: the projection, `find`, `key` or `observe` threw.
576
+ */
577
+ export declare type SignalEvaluation = {
578
+ readonly kind: "observed";
579
+ readonly signal: SignalRef;
580
+ readonly observation: KayAppSignalObservation;
581
+ } | {
582
+ readonly kind: "skipped";
583
+ readonly signal: SignalRef;
584
+ } | {
585
+ readonly kind: "blocked";
586
+ readonly signal: SignalRef;
587
+ readonly upstream?: SignalEvaluation;
588
+ } | {
589
+ readonly kind: "failed";
590
+ readonly signal: SignalRef;
591
+ readonly error: unknown;
592
+ };
593
+
594
+ /** The signal a result is about. */
595
+ declare interface SignalRef {
596
+ readonly id: string;
597
+ readonly version: string;
598
+ }
599
+
600
+ /**
601
+ * The upstream a chained signal runs after. `find` names the upstream subject (and where it sits
602
+ * in the transcript) for a downstream subject, or `undefined` when it has none, which blocks the
603
+ * downstream. `pass` uses the same filters a signals read uses; every one must hold.
604
+ */
605
+ export declare interface SignalUpstream<I> {
606
+ readonly signal: SignalDefinition<never>;
607
+ readonly find: (input: I, source: KayAppSignalSource) => {
608
+ readonly input: unknown;
609
+ readonly source: KayAppSignalSource;
610
+ } | undefined;
611
+ readonly pass: readonly KayAppSignalsAnswerFilter[];
612
+ }
613
+
440
614
  /** Per-call knobs for {@link WithJev}. Every field is optional; the host applies its own default
441
615
  * budget when none is given, so the Jev branch is never unbounded. */
442
616
  declare interface WithJevOptions {
package/dist/signals.js CHANGED
@@ -1,5 +1,120 @@
1
1
  // packages/plugin-sdk/dist/signals.js
2
2
  import { z } from "zod";
3
+
4
+ // packages/plugin-sdk/dist/signal-digest.js
5
+ import { createHash } from "node:crypto";
6
+ function signalDigest(value) {
7
+ const json = JSON.stringify(value);
8
+ const normalised = json === void 0 ? null : JSON.parse(json);
9
+ return `sha256:${createHash("sha256").update(canonicalJson(normalised)).digest("hex")}`;
10
+ }
11
+ function canonicalJson(value) {
12
+ if (Array.isArray(value)) {
13
+ return `[${value.map(canonicalJson).join(",")}]`;
14
+ }
15
+ if (value !== null && typeof value === "object") {
16
+ const record = value;
17
+ return `{${Object.keys(record).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(record[k])}`).join(",")}}`;
18
+ }
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ // packages/plugin-sdk/dist/signal-engine.js
23
+ var MAX_CHAIN_DEPTH = 16;
24
+ function createSignalEngine(observe) {
25
+ return {
26
+ async evaluate(definitions, input, source, options) {
27
+ const memo = /* @__PURE__ */ new Map();
28
+ const run = async (definition, subject, at, chain) => {
29
+ const signal = { id: definition.id, version: definition.version };
30
+ try {
31
+ const state = definition.project(subject);
32
+ if (state === void 0)
33
+ return { kind: "skipped", signal };
34
+ const key = definition.key?.(subject);
35
+ const memoKey = JSON.stringify([
36
+ definition.id,
37
+ definition.version,
38
+ at.sessionId,
39
+ at.entryId,
40
+ key ?? null,
41
+ signalDigest(state)
42
+ ]);
43
+ if (chain.includes(memoKey)) {
44
+ throw new Error(`signal '${definition.id}' is chained after itself`);
45
+ }
46
+ if (chain.length >= MAX_CHAIN_DEPTH) {
47
+ throw new Error(`signal '${definition.id}' is chained more than ${MAX_CHAIN_DEPTH} deep`);
48
+ }
49
+ const known = memo.get(memoKey);
50
+ if (known !== void 0)
51
+ return await known;
52
+ const pending = observeOne(definition, signal, subject, at, state, key, [...chain, memoKey]);
53
+ memo.set(memoKey, pending);
54
+ return await pending;
55
+ } catch (error) {
56
+ return { kind: "failed", signal, error };
57
+ }
58
+ };
59
+ const observeOne = async (definition, signal, subject, at, state, key, chain) => {
60
+ try {
61
+ let upstreamId;
62
+ if (definition.after !== void 0) {
63
+ const found = definition.after.find(subject, at);
64
+ if (found === void 0)
65
+ return { kind: "blocked", signal };
66
+ const upstream = await run(definition.after.signal, found.input, found.source, chain);
67
+ if (upstream.kind !== "observed" || upstream.observation.status !== "classified" || upstream.observation.id === void 0 || !signalAnswersPass(upstream.observation.answers, definition.after.pass)) {
68
+ return { kind: "blocked", signal, upstream };
69
+ }
70
+ upstreamId = upstream.observation.id;
71
+ }
72
+ const observation = await observe({
73
+ detector: signal,
74
+ source: at,
75
+ subject: {
76
+ kind: definition.subjectKind,
77
+ ...key === void 0 ? {} : { key }
78
+ },
79
+ ...upstreamId === void 0 ? {} : { after: { observationId: upstreamId } },
80
+ ...definition.maxAgeMs === void 0 ? {} : { reuse: { maxAgeMs: definition.maxAgeMs } },
81
+ state,
82
+ questions: definition.questions,
83
+ options: { ...options, purpose: `signals/${definition.id}` }
84
+ });
85
+ return { kind: "observed", signal, observation };
86
+ } catch (error) {
87
+ return { kind: "failed", signal, error };
88
+ }
89
+ };
90
+ const results = [];
91
+ for (const definition of definitions) {
92
+ const result = await run(definition, input, source, []);
93
+ results.push(result);
94
+ }
95
+ return results;
96
+ }
97
+ };
98
+ }
99
+ function signalAnswersPass(answers, filters) {
100
+ return filters.every((filter) => {
101
+ const answer = answers?.[filter.question];
102
+ if (typeof answer !== "object" || answer === null)
103
+ return false;
104
+ const record = answer;
105
+ const within = (value, range) => typeof value === "number" && (range.atLeast === void 0 || value >= range.atLeast) && (range.atMost === void 0 || value <= range.atMost);
106
+ if ("yes" in filter) {
107
+ const value = record["type"] === "bool" ? record["probability"] : record["type"] === "noul" ? record["noul"] : void 0;
108
+ return within(value, filter.yes);
109
+ }
110
+ if ("choice" in filter) {
111
+ return record["type"] === "choice" && typeof record["choice"] === "string" && filter.choice.includes(record["choice"]) && within(record["confidence"], { atLeast: filter.minConfidence ?? 0 });
112
+ }
113
+ return record["type"] === "score" && within(record["score"], filter.score);
114
+ });
115
+ }
116
+
117
+ // packages/plugin-sdk/dist/signals.js
3
118
  function createFallbackOnlySignals() {
4
119
  const noStore = { status: "unavailable", reason: "no-store" };
5
120
  return {
@@ -31,6 +146,9 @@ var RUN_ID = /^\P{Cc}+$/u;
31
146
  var POINTER_RULE = `1 to ${MAX_POINTER} characters with no whitespace, \`/\`, \`?\`, \`#\`, or control or invisible character`;
32
147
  var ObserveShape = z.looseObject({
33
148
  detector: z.looseObject({ id: z.string(), version: z.string() }),
149
+ subject: z.looseObject({ kind: z.string(), key: z.string().optional() }).optional(),
150
+ after: z.looseObject({ observationId: z.unknown() }).optional(),
151
+ reuse: z.looseObject({ maxAgeMs: z.unknown() }).optional(),
34
152
  source: z.looseObject({
35
153
  sessionId: z.string(),
36
154
  entryId: z.string(),
@@ -45,6 +163,7 @@ var ChoiceQuestion = z.looseObject({
45
163
  criteria: z.record(z.string(), z.unknown())
46
164
  });
47
165
  var fits = (value, grammar) => value.length <= MAX_NAME && grammar.test(value);
166
+ var isWhole = (value, min) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= SIGNAL_TIME_MAX_MS;
48
167
  var quoted = (value) => JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}\u2026` : value).replace(/(?! )[\s\p{C}]/gu, (char) => `\\u${char.codePointAt(0).toString(16).padStart(4, "0")}`);
49
168
  function grammarProblem(input) {
50
169
  const shape = ObserveShape.safeParse(input);
@@ -52,7 +171,7 @@ function grammarProblem(input) {
52
171
  const field = shape.error.issues[0]?.path.join(".") ?? "";
53
172
  return field === "" ? "`observe` takes an object with `detector`, `source`, `state`, and `questions`." : `\`${field}\` is missing or has the wrong type.`;
54
173
  }
55
- const { detector, source, questions } = shape.data;
174
+ const { detector, source, questions, subject, after, reuse } = shape.data;
56
175
  const { state } = input;
57
176
  if (typeof state !== "object" || state === null || Array.isArray(state)) {
58
177
  return "`state` must be a JSON object.";
@@ -82,6 +201,23 @@ function grammarProblem(input) {
82
201
  if (occurredAtMs !== void 0 && !(typeof occurredAtMs === "number" && Number.isSafeInteger(occurredAtMs) && occurredAtMs >= 0 && occurredAtMs <= SIGNAL_TIME_MAX_MS)) {
83
202
  return `\`source.occurredAtMs\` must be whole epoch milliseconds from 0 to ${SIGNAL_TIME_MAX_MS}.`;
84
203
  }
204
+ if (subject !== void 0 && !fits(subject.kind, DETECTOR_ID)) {
205
+ return "`subject.kind` must be a lowercase slug of at most 64 characters: a-z and 0-9 in words joined by single hyphens.";
206
+ }
207
+ if (subject?.key !== void 0 && !(subject.key.length <= MAX_POINTER && POINTER.test(subject.key))) {
208
+ return `\`subject.key\` must be ${POINTER_RULE}.`;
209
+ }
210
+ if (after !== void 0 && !isWhole(after.observationId, 1)) {
211
+ return "`after.observationId` must be a positive whole number: the id of a stored signal.";
212
+ }
213
+ if (reuse !== void 0) {
214
+ if (!isWhole(reuse.maxAgeMs, 1)) {
215
+ return `\`reuse.maxAgeMs\` must be whole milliseconds from 1 to ${SIGNAL_TIME_MAX_MS}.`;
216
+ }
217
+ if (subject?.key === void 0) {
218
+ return "`reuse` needs `subject.key`: a stored answer is reused only for the same subject.";
219
+ }
220
+ }
85
221
  const names = Object.keys(questions);
86
222
  if (names.length === 0 || names.length > MAX_QUESTIONS) {
87
223
  return `\`questions\` must ask 1 to ${MAX_QUESTIONS} questions.`;
@@ -132,5 +268,8 @@ export {
132
268
  MAX_CHOICE_LABELS,
133
269
  SIGNAL_TIME_MAX_MS,
134
270
  createFallbackOnlySignals,
135
- invalidObservation
271
+ createSignalEngine,
272
+ invalidObservation,
273
+ signalAnswersPass,
274
+ signalDigest
136
275
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaklabs-ai/plugin-sdk",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "The Kay plugin SDK — types and helpers for authoring a Kay directory plugin.",
5
5
  "author": "YakLabs",
6
6
  "type": "module",
@@ -1,11 +1,11 @@
1
1
  ---
2
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."
3
+ description: "Author, scaffold, debug, or iterate on a Bonsai directory plugin (one the user writes in ~/.kay/plugins/<name>). Use when creating a new local plugin, when a plugin does not appear in Bonsai 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
4
  ---
5
5
 
6
6
  # Directory plugin authoring
7
7
 
8
- A directory plugin is a plugin the user authors in `~/.kay/plugins/<name>/`, outside the Kay
8
+ A directory plugin is a plugin the user authors in `~/.kay/plugins/<name>/`, outside the Bonsai
9
9
  repo. The full authoring guide is [`docs/directory-plugins.md`](../../docs/directory-plugins.md)
10
10
  and the SDK reference is `packages/plugin-sdk/README.md`; this skill is the routing layer that
11
11
  keeps an agent on the paved path and out of the known pits.
@@ -13,7 +13,7 @@ keeps an agent on the paved path and out of the known pits.
13
13
  ## Confirm the checkout is current before you scaffold
14
14
 
15
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`
16
+ whichever Bonsai checkout invoked it, with no staleness warning. A checkout far behind `origin/main`
17
17
  — one was 752 commits behind — typechecks the new plugin against stale `@kay/plugin-sdk` types,
18
18
  and it looks clean: every wrong shape it produced (a `content` object instead of
19
19
  `run`/`openBeside` on `PaneActionDescriptor`, `SearchField`'s `onChange` instead of
@@ -40,7 +40,7 @@ pnpm --filter @kay/plugin-sdk build # once per checkout, if not built
40
40
  pnpm create-plugin --id com.example.widgets
41
41
  ```
42
42
 
43
- Run from the root of a Kay checkout. It writes a working plugin (manifest, entrypoint, tsconfig,
43
+ Run from the root of a Bonsai checkout. It writes a working plugin (manifest, entrypoint, tsconfig,
44
44
  README) that already typechecks and loads; `--tier starter` adds a URI module, a bundled skill,
45
45
  and a standalone `node --test` test to copy from. Add `--ui` (either tier) when the plugin needs a
46
46
  page: it writes `ui.tsx`, declares the `kay.ui` entrypoint, and generates the UI typecheck config
@@ -61,7 +61,7 @@ cannot happen.
61
61
  pnpm plugin:check ~/.kay/plugins/widgets
62
62
  ```
63
63
 
64
- Run from the root of a Kay checkout, against the plugin directory. It runs the daemon's OWN
64
+ Run from the root of a Bonsai checkout, against the plugin directory. It runs the daemon's OWN
65
65
  manifest schema and contract resolver (imported from `@kay/plugin-protocol`, not re-implemented),
66
66
  so it answers "would Kay accept this manifest?" in about a second instead of after a load cycle.
67
67
  Run it after every manifest edit, and before concluding a plugin is broken for some deeper reason.
@@ -87,10 +87,10 @@ disclosure audit, or an id another installed plugin already claims.
87
87
  exports exist).
88
88
  - The daemon's chord loader bundles each declared TypeScript entrypoint and its relative imports.
89
89
  It keeps `@kay/plugin-sdk` imports external and resolves them to the running app's own built SDK.
90
- Multi-file plugins are supported, and no plugin-local build is needed for Kay to load them.
90
+ Multi-file plugins are supported, and no plugin-local build is needed for Bonsai to load them.
91
91
  - Corollary: **bare `node` outside the daemon cannot resolve `@kay/plugin-sdk`** from the plugin
92
92
  directory unless the plugin has its own SDK dependency. That failure says nothing about whether
93
- the plugin will load in Kay. The `starter` tier's test setup creates the symlink needed for its
93
+ the plugin will load in Bonsai. The `starter` tier's test setup creates the symlink needed for its
94
94
  standalone test; a published SDK dependency is the other standalone option.
95
95
  - Typecheck with `npm run typecheck` inside the plugin directory; the scaffold's generated
96
96
  tsconfig maps the SDK's types via `paths`.
@@ -160,18 +160,18 @@ list` names the exact local root — because writing into the wrong one produces
160
160
  never appears and no diagnostic anywhere.
161
161
 
162
162
  2. **Enable it once.** Directory plugins are disabled on arrival (fail-closed: writing files in
163
- your own plugin folder is how Kay DISCOVERS it, not consent to run it). Flip the toggle in
164
- Settings → Plugins → your plugin's detail pane, or ask Kay to write the `installs` row's
163
+ your own plugin folder is how Bonsai DISCOVERS it, not consent to run it). Flip the toggle in
164
+ Settings → Plugins → your plugin's detail pane, or ask Bonsai to write the `installs` row's
165
165
  `enabled` field. Enabling reconciles live, and contributions reach the agent's **next**
166
166
  session, not the current one.
167
167
  3. **Edit, then commit.** `git add -A && git commit` makes the new code available.
168
- Kay loads an isolated snapshot of HEAD and activates it live; no push or restart
168
+ Bonsai loads an isolated snapshot of HEAD and activates it live; no push or restart
169
169
  is needed. Uncommitted edits, staged additions, deleted working files and runtime
170
170
  writes do not execute, including at cold boot. The detail pane's **Running
171
171
  commit** identifies the serving snapshot. Try new tools in a fresh session;
172
172
  an existing session may still hold its previous generation.
173
173
 
174
- Kay notices a commit within about 10 seconds even with no file events, and acquiring it may
174
+ Bonsai notices a commit within about 10 seconds even with no file events, and acquiring it may
175
175
  take up to 30 seconds more. A **Running commit** still behind `git rev-parse --short HEAD` a
176
176
  minute later, with a diagnostic on the plugin's row, is a failed read: fix what it names
177
177
  instead of refreshing and waiting. Without a diagnostic, refresh the `local` source. A failed committed
@@ -208,7 +208,7 @@ list` names the exact local root — because writing into the wrong one produces
208
208
  The commit is the activation boundary, so run these against the working tree first. Each catches
209
209
  a failure class the load would otherwise be the first thing to surface:
210
210
 
211
- 1. **Run `pnpm plugin:check <dir>`** from a Kay checkout. It validates `kay-plugin.json` against
211
+ 1. **Run `pnpm plugin:check <dir>`** from a Bonsai checkout. It validates `kay-plugin.json` against
212
212
  `KayPluginManifestV1` and resolves its contributions with the daemon's own resolver (both from
213
213
  `@kay/plugin-protocol`), not just "it is valid JSON": that schema and resolver are the gate the
214
214
  scan actually runs. A non-zero exit means the plugin would be refused.
@@ -230,12 +230,13 @@ the detail pane's **Running commit** row for the revision.
230
230
  One thing no commit proves: whether the UI entrypoint bundles in a **packaged** build. esbuild's
231
231
  native binary resolution can fail silently inside `app.asar` (`ui_entrypoint_bundle_failed`), and
232
232
  the only trace is `ui-app.directory-load-failed` in the day's `kay-<date>.jsonl` log —
233
- `plugin://<id>` still reads enabled with no failure noted, and the pane simply has no overflow
234
- menu. Current builds carry the fix (#8014). A dev app runs unpackaged and never exercises that
235
- path, so report the packaged case as unverified instead of assuming it either way.
233
+ `plugin://<id>` still reads enabled with no failure noted, and the pane's overflow menu simply
234
+ lacks the plugin's row. Current builds carry the fix (#8014). A dev app runs unpackaged and
235
+ never exercises that path, so report the packaged case as unverified instead of assuming it
236
+ either way.
236
237
 
237
238
  Dev-instance warning: a worktree dev app (`pnpm dev:kay`) is a **separate daemon** from the
238
- user's installed Kay, with its own config store, consent records, and plugin roster. A plugin
239
+ user's installed Bonsai, with its own config store, consent records, and plugin roster. A plugin
239
240
  visible in one is invisible to the other, and a `config://settings/pluginActivation` write from
240
241
  a session in one daemon validates against that daemon's roster only. #7732 was two daemons'
241
242
  outputs conflated into one impossible-looking bug; always ask "which daemon am I looking at?"
@@ -283,4 +284,4 @@ file.
283
284
  - [`docs/directory-plugins.md`](../../docs/directory-plugins.md) — the authoring guide:
284
285
  manifest shape, the enable gate and what it does not gate, trust tiers, diagnostics.
285
286
  - `packages/plugin-sdk/README.md` — the SDK surface.
286
- - `packages/plugin-sdk/PUBLISHED.md` — installing the published SDK outside a Kay checkout.
287
+ - `packages/plugin-sdk/PUBLISHED.md` — installing the published SDK outside a Bonsai checkout.