@roku-sdk/ai 0.0.0-stage → 2.19.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +47 -3
  4. package/skills/ads-and-raf/SKILL.md +231 -0
  5. package/skills/ads-and-raf/references/integration-flows.md +332 -0
  6. package/skills/app-packaging/SKILL.md +107 -0
  7. package/skills/app-performance/SKILL.md +133 -0
  8. package/skills/cli-dev-loop/SKILL.md +217 -0
  9. package/skills/cli-dev-loop/references/logs-and-crashes.md +114 -0
  10. package/skills/cpu-and-heap-profiling/SKILL.md +108 -0
  11. package/skills/cpu-and-heap-profiling/references/reading-profiles.md +52 -0
  12. package/skills/data-entry-keyboards/SKILL.md +160 -0
  13. package/skills/device-management/SKILL.md +158 -0
  14. package/skills/dialogs-and-modals/SKILL.md +210 -0
  15. package/skills/effects/SKILL.md +183 -0
  16. package/skills/fetch-and-data-loading/SKILL.md +164 -0
  17. package/skills/fetch-and-data-loading/references/https-and-certificates.md +79 -0
  18. package/skills/flex-grid/SKILL.md +236 -0
  19. package/skills/flex-grid/references/focus-integration.md +355 -0
  20. package/skills/flex-grid/references/plugins.md +201 -0
  21. package/skills/focus-management/SKILL.md +210 -0
  22. package/skills/focus-management/references/focus-recipes.md +290 -0
  23. package/skills/fonts-and-glyphs/SKILL.md +231 -0
  24. package/skills/fonts-and-glyphs/references/glyph-tables.md +128 -0
  25. package/skills/logging-and-errors/SKILL.md +227 -0
  26. package/skills/logging-and-errors/references/uncaught-exceptions.md +137 -0
  27. package/skills/mcp-ai-dev-loop/SKILL.md +189 -0
  28. package/skills/mcp-ai-dev-loop/references/crash-and-console.md +68 -0
  29. package/skills/mcp-ai-dev-loop/references/live-debugging.md +129 -0
  30. package/skills/mcp-setup/SKILL.md +131 -0
  31. package/skills/project-setup/SKILL.md +210 -0
  32. package/skills/project-setup/references/roku-config.md +120 -0
  33. package/skills/screen-navigation/SKILL.md +166 -0
  34. package/skills/sdk-components/SKILL.md +213 -0
  35. package/skills/sdk-components/references/dialogs.md +148 -0
  36. package/skills/sdk-components/references/scenegraph-node-spelling.md +123 -0
  37. package/skills/sdk-installation/SKILL.md +129 -0
  38. package/skills/slideshow/SKILL.md +157 -0
  39. package/skills/slideshow/references/focus-and-handoff.md +187 -0
  40. package/skills/solid-on-roku/SKILL.md +181 -0
  41. package/skills/solid-on-roku/references/hermes-and-exports.md +130 -0
  42. package/skills/testing/SKILL.md +147 -0
  43. package/skills/testing/references/async-and-timing.md +97 -0
  44. package/skills/user-accounts-and-purchases/SKILL.md +164 -0
  45. package/skills-manifest.json +316 -0
  46. package/skills-manifest.schema.json +62 -0
  47. package/src/__tests__/manifest.test.ts +267 -0
  48. package/src/__tests__/snippets.test.ts +221 -0
  49. package/src/__tests__/trigger-routing.test.ts +77 -0
  50. package/src/index.ts +36 -0
  51. package/src/manifest.ts +215 -0
  52. package/src/router.ts +147 -0
  53. package/src/snippets.ts +212 -0
package/src/router.ts ADDED
@@ -0,0 +1,147 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * @module router
6
+ * Trigger router + collision detection.
7
+ *
8
+ * `buildRouter()`/`route()` map a query keyword to the skill that owns it and
9
+ * guarantee triggers are unique. `findCollisionsAcross()` checks for the same
10
+ * trigger being claimed by skills from more than one manifest, and is reused by
11
+ * tooling discovery when merging manifests. This module is pure — no filesystem
12
+ * or network.
13
+ *
14
+ * Triggers are treated as case-insensitive keywords: routing and collision
15
+ * detection both normalize to lowercase.
16
+ */
17
+
18
+ import type { SkillManifest } from "./manifest";
19
+
20
+ /**
21
+ * A single trigger owned by more than one skill.
22
+ */
23
+ export interface TriggerCollision {
24
+ /** The normalized (lowercased) trigger keyword. */
25
+ trigger: string;
26
+ /** Names of the skills that claim this trigger, in first-seen order. */
27
+ skills: string[];
28
+ }
29
+
30
+ /**
31
+ * Resolves a query keyword to the skill that owns it.
32
+ */
33
+ export interface Router {
34
+ /** Returns the owning skill name for a query, or `null` when no trigger matches. */
35
+ route(query: string): string | null;
36
+ /** The full normalized trigger → skill-name map. */
37
+ entries(): ReadonlyMap<string, string>;
38
+ }
39
+
40
+ /**
41
+ * Thrown by {@link buildRouter} when a trigger is claimed by more than one skill.
42
+ *
43
+ * `@roku-sdk/ai` has no dependency on `@roku-sdk/tooling`, so this extends the
44
+ * native `Error`; tooling may wrap it to attach a shell exit code.
45
+ */
46
+ export class TriggerCollisionError extends Error {
47
+ /** Semantic code for logging / consumers. */
48
+ readonly code = "TRIGGER_COLLISION";
49
+ /** The collisions that caused the failure. */
50
+ readonly collisions: TriggerCollision[];
51
+
52
+ constructor(collisions: TriggerCollision[]) {
53
+ const detail = collisions.map(c => ` - "${c.trigger}" claimed by: ${c.skills.join(", ")}`).join("\n");
54
+ super(`Trigger collisions detected:\n${detail}`);
55
+ this.name = "TriggerCollisionError";
56
+ this.collisions = collisions;
57
+ }
58
+ }
59
+
60
+ function normalizeTrigger(trigger: string): string {
61
+ return trigger.trim().toLowerCase();
62
+ }
63
+
64
+ /**
65
+ * Detect triggers claimed by more than one skill across any number of manifests.
66
+ *
67
+ * Used both for a single manifest ({@link findCollisions}) and when tooling
68
+ * discovery merges manifests from several installed packages.
69
+ */
70
+ export function findCollisionsAcross(manifests: SkillManifest[]): TriggerCollision[] {
71
+ // normalized trigger -> ordered, de-duplicated list of owning skill names
72
+ const owners = new Map<string, string[]>();
73
+
74
+ for (const manifest of manifests) {
75
+ for (const skill of manifest.skills) {
76
+ const seenForSkill = new Set<string>();
77
+ for (const rawTrigger of skill.triggers) {
78
+ const trigger = normalizeTrigger(rawTrigger);
79
+ // A skill listing the same trigger twice is not a cross-skill collision.
80
+ if (seenForSkill.has(trigger)) {
81
+ continue;
82
+ }
83
+ seenForSkill.add(trigger);
84
+
85
+ const list = owners.get(trigger) ?? [];
86
+ if (!list.includes(skill.name)) {
87
+ list.push(skill.name);
88
+ }
89
+ owners.set(trigger, list);
90
+ }
91
+ }
92
+ }
93
+
94
+ const collisions: TriggerCollision[] = [];
95
+ for (const [trigger, skills] of owners) {
96
+ if (skills.length > 1) {
97
+ collisions.push({ trigger, skills });
98
+ }
99
+ }
100
+ return collisions;
101
+ }
102
+
103
+ /**
104
+ * Detect triggers claimed by more than one skill within a single manifest.
105
+ */
106
+ export function findCollisions(manifest: SkillManifest): TriggerCollision[] {
107
+ return findCollisionsAcross([manifest]);
108
+ }
109
+
110
+ /**
111
+ * Build a {@link Router} from a manifest.
112
+ *
113
+ * @throws {TriggerCollisionError} when any trigger is claimed by more than one skill.
114
+ */
115
+ export function buildRouter(manifest: SkillManifest): Router {
116
+ const collisions = findCollisions(manifest);
117
+ if (collisions.length > 0) {
118
+ throw new TriggerCollisionError(collisions);
119
+ }
120
+
121
+ const map = new Map<string, string>();
122
+ for (const skill of manifest.skills) {
123
+ for (const rawTrigger of skill.triggers) {
124
+ map.set(normalizeTrigger(rawTrigger), skill.name);
125
+ }
126
+ }
127
+
128
+ return {
129
+ route(query: string): string | null {
130
+ return map.get(normalizeTrigger(query)) ?? null;
131
+ },
132
+ entries(): ReadonlyMap<string, string> {
133
+ return map;
134
+ }
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Convenience: build a router for `manifest` and route `query` in one call.
140
+ *
141
+ * Prefer {@link buildRouter} when routing many queries against one manifest.
142
+ *
143
+ * @throws {TriggerCollisionError} when the manifest has trigger collisions.
144
+ */
145
+ export function route(manifest: SkillManifest, query: string): string | null {
146
+ return buildRouter(manifest).route(query);
147
+ }
@@ -0,0 +1,212 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * @module snippets
6
+ * TypeScript code samples embedded in a `SKILL.md`.
7
+ *
8
+ * A skill's sample is the part an agent copies, so it is the part that has to
9
+ * stay in step with the SDK. Extraction lives here, in the package that owns
10
+ * the skill format; the checking lives in internal tooling, because it needs
11
+ * the TypeScript compiler and the workspace layout and this package must stay
12
+ * dependency-free.
13
+ *
14
+ * Line numbers are absolute against the original file, so a diagnostic can be
15
+ * reported against the `SKILL.md` an author actually edits.
16
+ *
17
+ * This module is pure — no filesystem or network access.
18
+ */
19
+
20
+ /**
21
+ * How thoroughly a snippet is checked.
22
+ *
23
+ * - `default` — imports must resolve and every identifier must be imported.
24
+ * - `complete` — additionally typechecks, for fences that are whole modules.
25
+ * - `no-check` — skipped, for deliberate anti-examples.
26
+ */
27
+ export type SnippetMode = "complete" | "default" | "no-check";
28
+
29
+ /** One TypeScript code fence from a `SKILL.md`. */
30
+ export interface SkillSnippet {
31
+ /** Fence language tag, lowercased: `ts` or `tsx`. */
32
+ lang: "ts" | "tsx";
33
+ mode: SnippetMode;
34
+ /** Fence body, excluding the delimiter lines. */
35
+ text: string;
36
+ /** 1-based line of the first body line in the source file. */
37
+ startLine: number;
38
+ }
39
+
40
+ /** Closed set of extraction failures, so a consumer can map every one of them. */
41
+ export type SnippetExtractionCode = "SNIPPET_FENCE_UNCLOSED" | "SNIPPET_MARKER_INVALID";
42
+
43
+ /**
44
+ * Thrown by {@link extractSnippets} for markdown it cannot split into fences.
45
+ *
46
+ * `@roku-sdk/ai` has no dependency on `@roku-sdk/tooling`, so this extends the
47
+ * native `Error`; tooling may wrap it to attach a shell exit code.
48
+ */
49
+ export abstract class SnippetExtractionError extends Error {
50
+ /** Semantic code for logging / consumers. */
51
+ abstract readonly code: SnippetExtractionCode;
52
+ /** 1-based line of the fence's opening delimiter. */
53
+ readonly line: number;
54
+
55
+ constructor(message: string, line: number) {
56
+ super(`${message} (line ${line})`);
57
+ this.line = line;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Thrown by {@link extractSnippets} for a fence marker it cannot interpret.
63
+ *
64
+ * A marker that is present but unreadable must not degrade to the default
65
+ * mode: an author writing `rsg-skill=skip` is asking for the snippet to be
66
+ * exempt, and silently checking it instead would leave them arguing with a
67
+ * diagnostic they thought they had turned off.
68
+ */
69
+ export class SnippetMarkerError extends SnippetExtractionError {
70
+ readonly code = "SNIPPET_MARKER_INVALID";
71
+
72
+ constructor(message: string, line: number) {
73
+ super(message, line);
74
+ this.name = "SnippetMarkerError";
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Thrown by {@link extractSnippets} for a fence that is never closed.
80
+ *
81
+ * An unterminated fence has no end, so every line after it belongs to it:
82
+ * prose, headings and later fences all land in one snippet. Reporting that as
83
+ * syntax errors would point an author at their markdown, and when the open
84
+ * fence is not a TypeScript one it is worse than noisy — an unclosed `bash`
85
+ * fence swallows the `tsx` fences below it, and a file whose samples have all
86
+ * vanished is a file that passes.
87
+ */
88
+ export class UnclosedFenceError extends SnippetExtractionError {
89
+ readonly code = "SNIPPET_FENCE_UNCLOSED";
90
+
91
+ constructor(delimiter: string, line: number) {
92
+ // Spelled out rather than quoted: a backtick delimiter inside backticks
93
+ // prints as a row of five, which reads as noise in a terminal.
94
+ const character = delimiter.startsWith("~") ? "tildes" : "backticks";
95
+ super(`Code fence opened with ${delimiter.length} ${character} is never closed`, line);
96
+ this.name = "UnclosedFenceError";
97
+ }
98
+ }
99
+
100
+ /** Opening delimiter at column 0: three or more backticks or tildes. */
101
+ const OPENING = /^(`{3,}|~{3,})\s*(.*)$/;
102
+
103
+ /** The token that carries the mode, e.g. `tsx rsg-skill=complete`. */
104
+ const MARKER = "rsg-skill";
105
+
106
+ const MODES_BY_MARKER_VALUE: Readonly<Record<string, SnippetMode>> = {
107
+ complete: "complete",
108
+ "no-check": "no-check"
109
+ };
110
+
111
+ /**
112
+ * The mode a fence's info string asks for.
113
+ *
114
+ * Only the language is lowercased. Marker values are matched exactly so that
115
+ * the accepted spellings stay a closed set of two; `rsg-skill=Complete` is
116
+ * reported rather than accepted, because a second accepted spelling is a
117
+ * second thing to keep working forever.
118
+ */
119
+ function modeFromInfoWords(words: readonly string[], line: number): SnippetMode {
120
+ const markers = words.filter(word => word === MARKER || word.startsWith(`${MARKER}=`));
121
+ if (markers.length === 0) {
122
+ return "default";
123
+ }
124
+ if (markers.length > 1) {
125
+ throw new SnippetMarkerError(`Fence carries more than one \`${MARKER}\` marker: ${markers.join(" ")}`, line);
126
+ }
127
+
128
+ const accepted = Object.keys(MODES_BY_MARKER_VALUE)
129
+ .map(value => `${MARKER}=${value}`)
130
+ .join(", ");
131
+ const [marker] = markers;
132
+ if (marker === MARKER) {
133
+ throw new SnippetMarkerError(`Fence marker \`${MARKER}\` needs a value; expected one of ${accepted}`, line);
134
+ }
135
+
136
+ const mode = MODES_BY_MARKER_VALUE[marker.slice(MARKER.length + 1)];
137
+ if (mode === undefined) {
138
+ throw new SnippetMarkerError(`Unrecognised fence marker \`${marker}\`; expected one of ${accepted}`, line);
139
+ }
140
+ return mode;
141
+ }
142
+
143
+ /**
144
+ * Index of the closing delimiter, or `lines.length` when there is none. A
145
+ * closing fence uses the same character and is at least as long as the opening
146
+ * one.
147
+ */
148
+ function findClosing(lines: readonly string[], from: number, char: string, minLength: number): number {
149
+ const closing = new RegExp(`^\\${char}{${minLength},}\\s*$`);
150
+ for (let index = from; index < lines.length; index += 1) {
151
+ if (closing.test(lines[index])) {
152
+ return index;
153
+ }
154
+ }
155
+ return lines.length;
156
+ }
157
+
158
+ /**
159
+ * Split a `SKILL.md` into its TypeScript code fences.
160
+ *
161
+ * A fence must start at column 0, which excludes indented code blocks, inline
162
+ * code spans, and fences quoted inside a blockquote — none of which are the
163
+ * copyable sample this exists to check.
164
+ *
165
+ * Frontmatter is deliberately not stripped: `startLine` only means anything if
166
+ * it counts from the top of the real file, and a `---` block cannot be
167
+ * mistaken for a fence.
168
+ *
169
+ * Every fence must close, including the ones whose language this ignores: an
170
+ * open fence of any language consumes the fences below it, so tolerating one
171
+ * would mean returning fewer snippets than the file appears to hold.
172
+ *
173
+ * @param markdown Full file text.
174
+ * @throws {SnippetMarkerError} for a fence marker that cannot be interpreted.
175
+ * @throws {UnclosedFenceError} for a fence with no closing delimiter.
176
+ */
177
+ export function extractSnippets(markdown: string): SkillSnippet[] {
178
+ const lines = markdown.split("\n");
179
+ const snippets: SkillSnippet[] = [];
180
+
181
+ let index = 0;
182
+ while (index < lines.length) {
183
+ const opening = OPENING.exec(lines[index]);
184
+ if (opening === null) {
185
+ index += 1;
186
+ continue;
187
+ }
188
+
189
+ const [, delimiter, infoString] = opening;
190
+ const closingIndex = findClosing(lines, index + 1, delimiter[0], delimiter.length);
191
+ if (closingIndex === lines.length) {
192
+ throw new UnclosedFenceError(delimiter, index + 1);
193
+ }
194
+
195
+ const words = infoString.trim().split(/\s+/).filter(Boolean);
196
+ const lang = words[0]?.toLowerCase();
197
+
198
+ if (lang === "ts" || lang === "tsx") {
199
+ snippets.push({
200
+ lang,
201
+ mode: modeFromInfoWords(words.slice(1), index + 1),
202
+ text: lines.slice(index + 1, closingIndex).join("\n"),
203
+ // Lines are 1-based and the body starts after the delimiter.
204
+ startLine: index + 2
205
+ });
206
+ }
207
+
208
+ index = closingIndex + 1;
209
+ }
210
+
211
+ return snippets;
212
+ }