ikanban-web 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +65 -0
  2. package/bin/cli.js +1055 -0
  3. package/dist/apple-touch-icon-120x120.png +0 -0
  4. package/dist/apple-touch-icon-152x152.png +0 -0
  5. package/dist/apple-touch-icon-167x167.png +0 -0
  6. package/dist/apple-touch-icon-180x180.png +0 -0
  7. package/dist/apple-touch-icon.png +0 -0
  8. package/dist/apple-touch-icon.svg +16 -0
  9. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  10. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  33. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  36. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  39. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  51. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  54. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  57. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  60. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  62. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  65. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  68. package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
  69. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  70. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  71. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  72. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  73. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  74. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  75. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  76. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  77. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  78. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  79. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  80. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  81. package/dist/assets/index-BQJPAP5f.css +1 -0
  82. package/dist/assets/index-MBuF9PXk.js +2 -0
  83. package/dist/assets/main-C1GggQif.css +1 -0
  84. package/dist/assets/main-Dyy8WvPJ.js +311 -0
  85. package/dist/assets/vendor--DEobTDja.css +1 -0
  86. package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
  87. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  88. package/dist/assets/worker-suRQxAtO.js +155 -0
  89. package/dist/favicon-16.png +0 -0
  90. package/dist/favicon-32.png +0 -0
  91. package/dist/favicon.png +0 -0
  92. package/dist/favicon.svg +15 -0
  93. package/dist/index.html +325 -0
  94. package/dist/logo-dark-192x192.png +0 -0
  95. package/dist/logo-dark-512x512.svg +16 -0
  96. package/dist/logo-light-192x192.png +0 -0
  97. package/dist/logo-light-512x512.svg +16 -0
  98. package/dist/pwa-192.png +0 -0
  99. package/dist/pwa-512.png +0 -0
  100. package/dist/pwa-maskable-192.png +0 -0
  101. package/dist/pwa-maskable-512.png +0 -0
  102. package/dist/site.webmanifest +22 -0
  103. package/dist/sw.js +1 -0
  104. package/package.json +102 -0
  105. package/public/apple-touch-icon-120x120.png +0 -0
  106. package/public/apple-touch-icon-152x152.png +0 -0
  107. package/public/apple-touch-icon-167x167.png +0 -0
  108. package/public/apple-touch-icon-180x180.png +0 -0
  109. package/public/apple-touch-icon.png +0 -0
  110. package/public/apple-touch-icon.svg +16 -0
  111. package/public/favicon-16.png +0 -0
  112. package/public/favicon-32.png +0 -0
  113. package/public/favicon.png +0 -0
  114. package/public/favicon.svg +15 -0
  115. package/public/logo-dark-192x192.png +0 -0
  116. package/public/logo-dark-512x512.svg +16 -0
  117. package/public/logo-light-192x192.png +0 -0
  118. package/public/logo-light-512x512.svg +16 -0
  119. package/public/pwa-192.png +0 -0
  120. package/public/pwa-512.png +0 -0
  121. package/public/pwa-maskable-192.png +0 -0
  122. package/public/pwa-maskable-512.png +0 -0
  123. package/public/site.webmanifest +22 -0
  124. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
  125. package/server/index.d.ts +28 -0
  126. package/server/index.js +11181 -0
  127. package/server/lib/cloudflare-tunnel.js +196 -0
  128. package/server/lib/git-credentials.js +74 -0
  129. package/server/lib/git-identity-storage.js +110 -0
  130. package/server/lib/git-service.js +2690 -0
  131. package/server/lib/github-auth.js +307 -0
  132. package/server/lib/github-device-flow.js +50 -0
  133. package/server/lib/github-octokit.js +10 -0
  134. package/server/lib/github-repo.js +55 -0
  135. package/server/lib/notification-message.js +49 -0
  136. package/server/lib/notification-message.test.js +59 -0
  137. package/server/lib/opencode-auth.js +81 -0
  138. package/server/lib/opencode-config.js +1840 -0
  139. package/server/lib/opencode-config.js.d.ts +12 -0
  140. package/server/lib/package-manager.js +255 -0
  141. package/server/lib/quota/DOCUMENTATION.md +52 -0
  142. package/server/lib/quota/index.js +21 -0
  143. package/server/lib/quota/providers/claude.js +107 -0
  144. package/server/lib/quota/providers/codex.js +111 -0
  145. package/server/lib/quota/providers/copilot.js +165 -0
  146. package/server/lib/quota/providers/google/api.js +92 -0
  147. package/server/lib/quota/providers/google/auth.js +108 -0
  148. package/server/lib/quota/providers/google/index.js +124 -0
  149. package/server/lib/quota/providers/google/transforms.js +109 -0
  150. package/server/lib/quota/providers/index.js +128 -0
  151. package/server/lib/quota/providers/interface.js +55 -0
  152. package/server/lib/quota/providers/kimi.js +108 -0
  153. package/server/lib/quota/providers/nanogpt.js +124 -0
  154. package/server/lib/quota/providers/openai.js +91 -0
  155. package/server/lib/quota/providers/openrouter.js +92 -0
  156. package/server/lib/quota/providers/zai.js +91 -0
  157. package/server/lib/quota/utils/auth.js +46 -0
  158. package/server/lib/quota/utils/formatters.js +76 -0
  159. package/server/lib/quota/utils/index.js +10 -0
  160. package/server/lib/quota/utils/transformers.js +55 -0
  161. package/server/lib/skills-catalog/cache.js +29 -0
  162. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  163. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  164. package/server/lib/skills-catalog/clawdhub/install.js +223 -0
  165. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  166. package/server/lib/skills-catalog/curated-sources.js +21 -0
  167. package/server/lib/skills-catalog/git.js +76 -0
  168. package/server/lib/skills-catalog/install.js +280 -0
  169. package/server/lib/skills-catalog/scan.js +221 -0
  170. package/server/lib/skills-catalog/source.js +85 -0
  171. package/server/lib/summarization-service.js +171 -0
  172. package/server/lib/terminal-input-ws-protocol.js +66 -0
  173. package/server/lib/terminal-input-ws-protocol.test.js +138 -0
  174. package/server/lib/tts-service.js +162 -0
  175. package/server/lib/ui-auth.js +518 -0
@@ -0,0 +1,221 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import yaml from 'yaml';
5
+
6
+ import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
7
+ import { parseSkillRepoSource } from './source.js';
8
+
9
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
10
+
11
+ function validateSkillName(skillName) {
12
+ if (typeof skillName !== 'string') return false;
13
+ if (skillName.length < 1 || skillName.length > 64) return false;
14
+ return SKILL_NAME_PATTERN.test(skillName);
15
+ }
16
+
17
+ function parseSkillMd(content) {
18
+ const text = typeof content === 'string' ? content : '';
19
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
20
+ if (!match) {
21
+ return {
22
+ ok: true,
23
+ frontmatter: {},
24
+ warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'],
25
+ };
26
+ }
27
+
28
+ try {
29
+ const frontmatter = yaml.parse(match[1]) || {};
30
+ return { ok: true, frontmatter, warnings: [] };
31
+ } catch {
32
+ return {
33
+ ok: true,
34
+ frontmatter: {},
35
+ warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'],
36
+ };
37
+ }
38
+ }
39
+
40
+ async function safeRm(dir) {
41
+ try {
42
+ await fs.promises.rm(dir, { recursive: true, force: true });
43
+ } catch {
44
+ // ignore
45
+ }
46
+ }
47
+
48
+ async function cloneRepo({ cloneUrl, identity, tempDir }) {
49
+ const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
50
+ const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
51
+
52
+ const result = await runGit(preferred, { identity, timeoutMs: 60_000 });
53
+ if (result.ok) return { ok: true };
54
+
55
+ const fallbackResult = await runGit(fallback, { identity, timeoutMs: 60_000 });
56
+ if (fallbackResult.ok) return { ok: true };
57
+
58
+ return {
59
+ ok: false,
60
+ error: fallbackResult,
61
+ };
62
+ }
63
+
64
+ export async function scanSkillsRepository({
65
+ source,
66
+ subpath,
67
+ defaultSubpath,
68
+ identity,
69
+ } = {}) {
70
+ const gitCheck = await assertGitAvailable();
71
+ if (!gitCheck.ok) {
72
+ return { ok: false, error: gitCheck.error };
73
+ }
74
+
75
+ const parsed = parseSkillRepoSource(source, { subpath });
76
+ if (!parsed.ok) {
77
+ return { ok: false, error: parsed.error };
78
+ }
79
+
80
+ const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
81
+ const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
82
+
83
+ const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ikanban-skills-scan-'));
84
+
85
+ try {
86
+ const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
87
+ if (!cloned.ok) {
88
+ const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
89
+ if (looksLikeAuthError(msg)) {
90
+ return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
91
+ }
92
+ return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
93
+ }
94
+
95
+ const toFsPath = (posixPath) => path.join(tempBase, ...String(posixPath || '').split('/').filter(Boolean));
96
+
97
+ const patterns = effectiveSubpath
98
+ ? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`]
99
+ : ['SKILL.md', '**/SKILL.md'];
100
+
101
+ let skillMdPaths = null;
102
+
103
+ // Fast path: sparse checkout only SKILL.md files, then parse from disk.
104
+ // This avoids one `git show` per skill.
105
+ const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { identity, timeoutMs: 15_000 });
106
+ if (sparseInit.ok) {
107
+ const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { identity, timeoutMs: 30_000 });
108
+ if (sparseSet.ok) {
109
+ const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
110
+ if (checkout.ok) {
111
+ const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { identity, timeoutMs: 15_000 });
112
+ if (lsFiles.ok) {
113
+ skillMdPaths = lsFiles.stdout
114
+ .split(/\r?\n/)
115
+ .map((line) => line.trim())
116
+ .filter(Boolean)
117
+ .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ // Fallback: list tree and read SKILL.md blobs via git.
124
+ if (!Array.isArray(skillMdPaths)) {
125
+ const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD'];
126
+ if (effectiveSubpath) {
127
+ listArgs.push('--', effectiveSubpath);
128
+ }
129
+
130
+ const listResult = await runGit(listArgs, { identity, timeoutMs: 30_000 });
131
+ if (!listResult.ok) {
132
+ // If subpath doesn't exist, treat as empty scan.
133
+ return {
134
+ ok: true,
135
+ normalizedRepo: parsed.normalizedRepo,
136
+ effectiveSubpath,
137
+ items: [],
138
+ };
139
+ }
140
+
141
+ skillMdPaths = listResult.stdout
142
+ .split(/\r?\n/)
143
+ .map((line) => line.trim())
144
+ .filter(Boolean)
145
+ .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
146
+ }
147
+
148
+ // Root-level SKILL.md doesn't map cleanly to OpenCode's "skill name == folder name" convention.
149
+ const uniqueSkillDirs = Array.from(
150
+ new Set(
151
+ skillMdPaths
152
+ .filter((p) => p !== 'SKILL.md')
153
+ .map((p) => path.posix.dirname(p))
154
+ )
155
+ );
156
+
157
+ const items = [];
158
+ const maxParallel = 10;
159
+ let idx = 0;
160
+
161
+ const worker = async () => {
162
+ while (idx < uniqueSkillDirs.length) {
163
+ const skillDir = uniqueSkillDirs[idx++];
164
+ const skillName = path.posix.basename(skillDir);
165
+ const skillMdPath = path.posix.join(skillDir, 'SKILL.md');
166
+
167
+ const warnings = [];
168
+ let skillMdContent = '';
169
+
170
+ // Prefer filesystem reads when sparse checkout succeeded.
171
+ const filePath = toFsPath(skillMdPath);
172
+ try {
173
+ skillMdContent = await fs.promises.readFile(filePath, 'utf8');
174
+ } catch {
175
+ const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { identity, timeoutMs: 15_000 });
176
+ if (!showResult.ok) {
177
+ warnings.push('Failed to read SKILL.md');
178
+ } else {
179
+ skillMdContent = showResult.stdout;
180
+ }
181
+ }
182
+
183
+ const parsedMd = parseSkillMd(skillMdContent);
184
+ warnings.push(...(parsedMd.warnings || []));
185
+
186
+ const description = typeof parsedMd.frontmatter?.description === 'string' ? parsedMd.frontmatter.description : undefined;
187
+ const frontmatterName = typeof parsedMd.frontmatter?.name === 'string' ? parsedMd.frontmatter.name : undefined;
188
+
189
+ const installable = validateSkillName(skillName);
190
+ if (!installable) {
191
+ warnings.push('Skill directory name is not a valid OpenCode skill name');
192
+ }
193
+
194
+ items.push({
195
+ repoSource: source,
196
+ repoSubpath: effectiveSubpath || undefined,
197
+ skillDir,
198
+ skillName,
199
+ frontmatterName,
200
+ description,
201
+ installable,
202
+ warnings: warnings.length ? warnings : undefined,
203
+ });
204
+ }
205
+ };
206
+
207
+ await Promise.all(Array.from({ length: Math.min(maxParallel, uniqueSkillDirs.length || 1) }, () => worker()));
208
+
209
+ // Stable ordering for UX
210
+ items.sort((a, b) => a.skillName.localeCompare(b.skillName));
211
+
212
+ return {
213
+ ok: true,
214
+ normalizedRepo: parsed.normalizedRepo,
215
+ effectiveSubpath,
216
+ items,
217
+ };
218
+ } finally {
219
+ await safeRm(tempBase);
220
+ }
221
+ }
@@ -0,0 +1,85 @@
1
+ const GITHUB_HOST = 'github.com';
2
+
3
+ function normalizeGitHubOwnerRepo(owner, repo) {
4
+ const normalizedOwner = String(owner || '').trim();
5
+ const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, '');
6
+ if (!normalizedOwner || !normalizedRepo) {
7
+ return null;
8
+ }
9
+ return { owner: normalizedOwner, repo: normalizedRepo };
10
+ }
11
+
12
+ export function parseSkillRepoSource(input, options = {}) {
13
+ const raw = typeof input === 'string' ? input.trim() : '';
14
+ if (!raw) {
15
+ return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } };
16
+ }
17
+
18
+ const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null;
19
+
20
+ // SSH URL: git@github.com:owner/repo(.git)
21
+ const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i);
22
+ if (sshMatch) {
23
+ const parsed = normalizeGitHubOwnerRepo(sshMatch[1], sshMatch[2]);
24
+ if (!parsed) {
25
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid SSH repository URL' } };
26
+ }
27
+
28
+ return {
29
+ ok: true,
30
+ host: GITHUB_HOST,
31
+ owner: parsed.owner,
32
+ repo: parsed.repo,
33
+ cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
34
+ cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
35
+ // For SSH URLs, subpath is only accepted via options.subpath
36
+ effectiveSubpath: explicitSubpath,
37
+ normalizedRepo: `${parsed.owner}/${parsed.repo}`,
38
+ };
39
+ }
40
+
41
+ // HTTPS URL: https://github.com/owner/repo(.git)
42
+ const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i);
43
+ if (httpsMatch) {
44
+ const parsed = normalizeGitHubOwnerRepo(httpsMatch[1], httpsMatch[2]);
45
+ if (!parsed) {
46
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid HTTPS repository URL' } };
47
+ }
48
+
49
+ return {
50
+ ok: true,
51
+ host: GITHUB_HOST,
52
+ owner: parsed.owner,
53
+ repo: parsed.repo,
54
+ cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
55
+ cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
56
+ effectiveSubpath: explicitSubpath,
57
+ normalizedRepo: `${parsed.owner}/${parsed.repo}`,
58
+ };
59
+ }
60
+
61
+ // Shorthand: owner/repo[/subpath...]
62
+ const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
63
+ if (shorthandMatch) {
64
+ const parsed = normalizeGitHubOwnerRepo(shorthandMatch[1], shorthandMatch[2]);
65
+ if (!parsed) {
66
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } };
67
+ }
68
+
69
+ const shorthandSubpath = typeof shorthandMatch[3] === 'string' && shorthandMatch[3].trim() ? shorthandMatch[3].trim() : null;
70
+ const effectiveSubpath = explicitSubpath || shorthandSubpath;
71
+
72
+ return {
73
+ ok: true,
74
+ host: GITHUB_HOST,
75
+ owner: parsed.owner,
76
+ repo: parsed.repo,
77
+ cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
78
+ cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
79
+ effectiveSubpath,
80
+ normalizedRepo: `${parsed.owner}/${parsed.repo}`,
81
+ };
82
+ }
83
+
84
+ return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
85
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Text Summarization Service
3
+ *
4
+ * Uses the opencode.ai zen API with gpt-5-nano for fast, lightweight summarization.
5
+ * Used by all TTS implementations (Browser, Say, OpenAI).
6
+ */
7
+
8
+ function buildSummarizationPrompt(maxLength) {
9
+ return `You are a text summarizer for text-to-speech output. Create a concise, natural-sounding summary that captures the key points. Keep the summary under ${maxLength} characters.
10
+
11
+ CRITICAL INSTRUCTIONS:
12
+ 1. Output ONLY the final summary - no thinking, no reasoning, no explanations
13
+ 2. Do not show your work or thought process
14
+ 3. Do not use any special characters, markdown, code, URLs, file paths, or formatting
15
+ 4. Do not include phrases like "Here's a summary" or "In summary"
16
+ 5. Just provide clean, speakable text that can be read aloud
17
+ 6. Stay within the ${maxLength} character limit
18
+
19
+ Your response should be ready to speak immediately.`;
20
+ }
21
+
22
+ const SUMMARIZE_TIMEOUT_MS = 30_000;
23
+
24
+ /**
25
+ * Sanitize text for TTS output
26
+ * Removes markdown, URLs, file paths, and other non-speakable content
27
+ */
28
+ export function sanitizeForTTS(text) {
29
+ if (!text || typeof text !== 'string') return '';
30
+
31
+ return text
32
+ // Remove markdown formatting
33
+ .replace(/[*_~`#]/g, '')
34
+ // Remove code blocks
35
+ .replace(/```[\s\S]*?```/g, '')
36
+ .replace(/`[^`]*`/g, '')
37
+ // Remove shell-like command patterns
38
+ .replace(/^\s*[$#>]\s*/gm, '')
39
+ // Remove common shell operators
40
+ .replace(/[|&;<>]/g, ' ')
41
+ // Remove backslashes (escape characters)
42
+ .replace(/\\/g, '')
43
+ // Remove brackets that might be interpreted specially
44
+ .replace(/[[\]{}()]/g, '')
45
+ // Remove quotes that might cause issues
46
+ .replace(/["']/g, '')
47
+ // Remove URLs
48
+ .replace(/https?:\/\/[^\s]+/g, ' a link ')
49
+ // Remove file paths
50
+ .replace(/\/[\w\-./]+/g, '')
51
+ // Collapse multiple spaces/newlines
52
+ .replace(/\s+/g, ' ')
53
+ .trim();
54
+ }
55
+
56
+ /**
57
+ * Extract text from zen API response
58
+ */
59
+ function extractZenOutputText(data) {
60
+ if (!data || typeof data !== 'object') return null;
61
+ const output = data.output;
62
+ if (!Array.isArray(output)) return null;
63
+
64
+ const messageItem = output.find(
65
+ (item) => item && typeof item === 'object' && item.type === 'message'
66
+ );
67
+ if (!messageItem) return null;
68
+
69
+ const content = messageItem.content;
70
+ if (!Array.isArray(content)) return null;
71
+
72
+ const textItem = content.find(
73
+ (item) => item && typeof item === 'object' && item.type === 'output_text'
74
+ );
75
+
76
+ const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
77
+ return text || null;
78
+ }
79
+
80
+ /**
81
+ * Summarize text using the opencode.ai zen API
82
+ *
83
+ * @param {Object} options
84
+ * @param {string} options.text - The text to summarize
85
+ * @param {number} options.threshold - Character threshold (don't summarize if under this length)
86
+ * @param {number} options.maxLength - Maximum character length for the summary output (50-2000)
87
+ * @param {string} [options.zenModel] - Override zen model (defaults to gpt-5-nano)
88
+ * @returns {Promise<{summary: string, summarized: boolean, reason?: string}>}
89
+ */
90
+ export async function summarizeText({
91
+ text,
92
+ threshold = 200,
93
+ maxLength = 500,
94
+ zenModel,
95
+ }) {
96
+ // Don't summarize if text is under threshold
97
+ if (!text || text.length <= threshold) {
98
+ return {
99
+ summary: sanitizeForTTS(text || ''),
100
+ summarized: false,
101
+ reason: text ? 'Text under threshold' : 'No text provided',
102
+ };
103
+ }
104
+
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), SUMMARIZE_TIMEOUT_MS);
107
+
108
+ try {
109
+ const prompt = buildSummarizationPrompt(maxLength);
110
+
111
+ const response = await fetch('https://opencode.ai/zen/v1/responses', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({
115
+ model: zenModel || 'gpt-5-nano',
116
+ input: [
117
+ { role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` },
118
+ ],
119
+ stream: false,
120
+ reasoning: { effort: 'low' },
121
+ }),
122
+ signal: controller.signal,
123
+ });
124
+
125
+ if (!response.ok) {
126
+ const errorBody = await response.json().catch(() => ({}));
127
+ console.error('[Summarize] zen API error:', response.status, errorBody);
128
+ return {
129
+ summary: sanitizeForTTS(text),
130
+ summarized: false,
131
+ reason: `zen API returned ${response.status}`,
132
+ };
133
+ }
134
+
135
+ const data = await response.json();
136
+ const summary = extractZenOutputText(data);
137
+
138
+ if (summary) {
139
+ const sanitized = sanitizeForTTS(summary);
140
+ return {
141
+ summary: sanitized,
142
+ summarized: true,
143
+ originalLength: text.length,
144
+ summaryLength: sanitized.length,
145
+ };
146
+ }
147
+
148
+ return {
149
+ summary: sanitizeForTTS(text),
150
+ summarized: false,
151
+ reason: 'No response from model',
152
+ };
153
+ } catch (error) {
154
+ if (error.name === 'AbortError') {
155
+ console.error('[Summarize] Request timed out');
156
+ return {
157
+ summary: sanitizeForTTS(text),
158
+ summarized: false,
159
+ reason: 'Request timed out',
160
+ };
161
+ }
162
+ console.error('[Summarize] Error:', error);
163
+ return {
164
+ summary: sanitizeForTTS(text),
165
+ summarized: false,
166
+ reason: error.message,
167
+ };
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+ }
@@ -0,0 +1,66 @@
1
+ export const TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws';
2
+ export const TERMINAL_INPUT_WS_CONTROL_TAG_JSON = 0x01;
3
+ export const TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
4
+
5
+ export const parseRequestPathname = (requestUrl) => {
6
+ if (typeof requestUrl !== 'string' || requestUrl.length === 0) {
7
+ return '';
8
+ }
9
+
10
+ try {
11
+ return new URL(requestUrl, 'http://localhost').pathname;
12
+ } catch {
13
+ return '';
14
+ }
15
+ };
16
+
17
+ export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
18
+ if (Buffer.isBuffer(rawData)) {
19
+ return rawData;
20
+ }
21
+
22
+ if (Array.isArray(rawData)) {
23
+ return Buffer.concat(rawData.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))));
24
+ }
25
+
26
+ return Buffer.from(rawData);
27
+ };
28
+
29
+ export const normalizeTerminalInputWsMessageToText = (rawData) => {
30
+ if (typeof rawData === 'string') {
31
+ return rawData;
32
+ }
33
+
34
+ return normalizeTerminalInputWsMessageToBuffer(rawData).toString('utf8');
35
+ };
36
+
37
+ export const readTerminalInputWsControlFrame = (rawData) => {
38
+ if (!rawData) {
39
+ return null;
40
+ }
41
+
42
+ const buffer = normalizeTerminalInputWsMessageToBuffer(rawData);
43
+ if (buffer.length < 2 || buffer[0] !== TERMINAL_INPUT_WS_CONTROL_TAG_JSON) {
44
+ return null;
45
+ }
46
+
47
+ try {
48
+ const parsed = JSON.parse(buffer.subarray(1).toString('utf8'));
49
+ if (!parsed || typeof parsed !== 'object') {
50
+ return null;
51
+ }
52
+ return parsed;
53
+ } catch {
54
+ return null;
55
+ }
56
+ };
57
+
58
+ export const createTerminalInputWsControlFrame = (payload) => {
59
+ const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
60
+ return Buffer.concat([Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]), jsonBytes]);
61
+ };
62
+
63
+ export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
64
+ timestamps.filter((timestamp) => now - timestamp < windowMs);
65
+
66
+ export const isRebindRateLimited = (timestamps, maxPerWindow) => timestamps.length >= maxPerWindow;
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
5
+ TERMINAL_INPUT_WS_PATH,
6
+ createTerminalInputWsControlFrame,
7
+ isRebindRateLimited,
8
+ normalizeTerminalInputWsMessageToBuffer,
9
+ normalizeTerminalInputWsMessageToText,
10
+ parseRequestPathname,
11
+ pruneRebindTimestamps,
12
+ readTerminalInputWsControlFrame,
13
+ } from './terminal-input-ws-protocol.js';
14
+
15
+ describe('terminal input websocket protocol', () => {
16
+ it('uses fixed websocket path', () => {
17
+ expect(TERMINAL_INPUT_WS_PATH).toBe('/api/terminal/input-ws');
18
+ });
19
+
20
+ it('encodes control frames with control tag prefix', () => {
21
+ const frame = createTerminalInputWsControlFrame({ t: 'ok', v: 1 });
22
+ expect(frame[0]).toBe(TERMINAL_INPUT_WS_CONTROL_TAG_JSON);
23
+ });
24
+
25
+ it('roundtrips control frame payload', () => {
26
+ const payload = { t: 'b', s: 'abc123', v: 1 };
27
+ const frame = createTerminalInputWsControlFrame(payload);
28
+ expect(readTerminalInputWsControlFrame(frame)).toEqual(payload);
29
+ });
30
+
31
+ it('rejects control frame without protocol tag', () => {
32
+ const frame = Buffer.from(JSON.stringify({ t: 'b', s: 'abc123' }), 'utf8');
33
+ expect(readTerminalInputWsControlFrame(frame)).toBeNull();
34
+ });
35
+
36
+ it('rejects malformed control json', () => {
37
+ const frame = Buffer.concat([
38
+ Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
39
+ Buffer.from('{not json', 'utf8'),
40
+ ]);
41
+ expect(readTerminalInputWsControlFrame(frame)).toBeNull();
42
+ });
43
+
44
+ it('rejects empty control payloads', () => {
45
+ expect(readTerminalInputWsControlFrame(null)).toBeNull();
46
+ expect(readTerminalInputWsControlFrame(undefined)).toBeNull();
47
+ expect(readTerminalInputWsControlFrame(Buffer.alloc(0))).toBeNull();
48
+ });
49
+
50
+ it('rejects control json that is not object', () => {
51
+ const frame = Buffer.concat([
52
+ Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
53
+ Buffer.from('"str"', 'utf8'),
54
+ ]);
55
+ expect(readTerminalInputWsControlFrame(frame)).toBeNull();
56
+ });
57
+
58
+ it('parses control frame from chunk arrays', () => {
59
+ const frame = createTerminalInputWsControlFrame({ t: 'bok', v: 1 });
60
+ const chunks = [frame.subarray(0, 2), frame.subarray(2)];
61
+ expect(readTerminalInputWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
62
+ });
63
+
64
+ it('normalizes buffer passthrough', () => {
65
+ const raw = Buffer.from('abc', 'utf8');
66
+ const normalized = normalizeTerminalInputWsMessageToBuffer(raw);
67
+ expect(normalized).toBe(raw);
68
+ expect(normalized.toString('utf8')).toBe('abc');
69
+ });
70
+
71
+ it('normalizes uint8 arrays', () => {
72
+ const normalized = normalizeTerminalInputWsMessageToBuffer(new Uint8Array([97, 98, 99]));
73
+ expect(normalized.toString('utf8')).toBe('abc');
74
+ });
75
+
76
+ it('normalizes array buffer payloads', () => {
77
+ const source = new Uint8Array([97, 98, 99]).buffer;
78
+ const normalized = normalizeTerminalInputWsMessageToBuffer(source);
79
+ expect(normalized.toString('utf8')).toBe('abc');
80
+ });
81
+
82
+ it('normalizes chunk array payloads', () => {
83
+ const normalized = normalizeTerminalInputWsMessageToBuffer([
84
+ Buffer.from('ab', 'utf8'),
85
+ Buffer.from('c', 'utf8'),
86
+ ]);
87
+ expect(normalized.toString('utf8')).toBe('abc');
88
+ });
89
+
90
+ it('normalizes text payload from string', () => {
91
+ expect(normalizeTerminalInputWsMessageToText('\u001b[A')).toBe('\u001b[A');
92
+ });
93
+
94
+ it('normalizes text payload from binary data', () => {
95
+ expect(normalizeTerminalInputWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
96
+ });
97
+
98
+ it('parses relative request pathname', () => {
99
+ expect(parseRequestPathname('/api/terminal/input-ws?x=1')).toBe('/api/terminal/input-ws');
100
+ });
101
+
102
+ it('parses absolute request pathname', () => {
103
+ expect(parseRequestPathname('http://localhost:3000/api/terminal/input-ws')).toBe('/api/terminal/input-ws');
104
+ });
105
+
106
+ it('returns empty pathname for non-string request url', () => {
107
+ expect(parseRequestPathname(null)).toBe('');
108
+ });
109
+
110
+ it('returns empty pathname for invalid request url', () => {
111
+ expect(parseRequestPathname('http://')).toBe('');
112
+ expect(parseRequestPathname('')).toBe('');
113
+ });
114
+
115
+ it('prunes stale rebind timestamps', () => {
116
+ const now = 1_000;
117
+ const pruned = pruneRebindTimestamps([100, 200, 950, 999], now, 100);
118
+ expect(pruned).toEqual([950, 999]);
119
+ });
120
+
121
+ it('keeps rebind timestamps within active window', () => {
122
+ const now = 1_000;
123
+ const pruned = pruneRebindTimestamps([920, 950, 999], now, 100);
124
+ expect(pruned).toEqual([920, 950, 999]);
125
+ });
126
+
127
+ it('does not rate limit below threshold', () => {
128
+ expect(isRebindRateLimited([1, 2, 3], 4)).toBe(false);
129
+ });
130
+
131
+ it('does not rate limit empty window', () => {
132
+ expect(isRebindRateLimited([], 1)).toBe(false);
133
+ });
134
+
135
+ it('rate limits at threshold', () => {
136
+ expect(isRebindRateLimited([1, 2, 3, 4], 4)).toBe(true);
137
+ });
138
+ });