@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1

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 (311) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Edit a video into a new version: the tabs of the Edit modal post here.
3
+ *
4
+ * The form is `video-edit.js`; the work is `videos/cmd-tools-video-edit.sh`, run on the host in a screen
5
+ * session like every other long action. Only a new section spoken with ElevenLabs spends credits, and that
6
+ * needs `confirm=yes`, which only the confirm dialog's button sends.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const { esc } = require('../html');
13
+ const { workspaceDir } = require('../workspaces');
14
+ const { startInScreen, screenStartedHtml } = require('../jobs');
15
+ const voice = require('../audio-voice');
16
+ const videoEdit = require('../video-edit');
17
+
18
+ async function video_edit({ form, send, workspace }) {
19
+ if (workspace !== 'videos') return send('<div class="msg error">Videos are edited in the videos workspace.</div>', 400);
20
+ const found = form.mode === 'sections' ? await voice.loadEngines() : { engines: [] };
21
+ const p = videoEdit.plan(form, found.engines);
22
+ if (p.error) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
23
+ if (p.spends && form.confirm !== 'yes') return send(videoEdit.spendHtml(p));
24
+ const stringsFile = Object.keys(p.strings).length ? videoEdit.writeStrings(p.strings) : null;
25
+ const opts = { workspace, project: path.basename(p.out, '.mp4'), projectDir: workspaceDir('videos'), verb: 'edit',
26
+ cmdLine: videoEdit.commandLine(p, stringsFile, true), label: `video edit ${p.out}` };
27
+ const started = await startInScreen(opts);
28
+ if (!started.ok) {
29
+ if (stringsFile) fs.rmSync(path.dirname(stringsFile), { recursive: true, force: true });
30
+ return send(screenStartedHtml(started, opts.label, opts.project), started.already ? 409 : 500);
31
+ }
32
+ return send(voice.progressHtml({ kind: 'video', file: p.out, session: started.session, t0: Date.now(), engine: '', edit: '1' }, null));
33
+ }
34
+
35
+ // Ask the AI to make the change: a Claude Code screen in the videos folder, briefed with the video, its script,
36
+ // the tools, the rules, the settings already chosen on the tab, and the person's own words.
37
+ async function video_edit_ai({ form, send, res }) {
38
+ const brief = videoEdit.askBrief(form);
39
+ if (brief.error) return send(`<div class="msg error">${esc(brief.error)}</div>`, 400);
40
+ let out = null;
41
+ await require('./screens')['/actions/screen-new']({
42
+ form: { target: brief.target, kind: 'claude', session: brief.session, command: brief.prompt },
43
+ send: (html, status = 200) => { out = { html, status }; },
44
+ res,
45
+ });
46
+ if (!out || out.status >= 400) return send((out && out.html) || '<div class="msg error">The session did not start.</div>', (out && out.status) || 500);
47
+ const sessionName = `${brief.target.startsWith('p:') ? brief.target.slice(2).replace(':', '--') : 'videos'}--${brief.session}`.toLowerCase();
48
+ return send(`<div class="msg assistant">
49
+ <p>The AI (Claude Code) is working on <strong>${esc(brief.file)}</strong> in the session <code>${esc(sessionName)}</code>.</p>
50
+ <p class="uk-text-meta">Watch it, answer it and approve its steps on <a href="/screens">Screens</a>, or <code>screen -r ${esc(sessionName)}</code> in your own terminal.
51
+ The new version appears in the list when it is done; the original is not touched.</p></div>`);
52
+ }
53
+
54
+ module.exports = { '/actions/video-edit': video_edit, '/actions/video-edit-ai': video_edit_ai };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * What "@" and "/" can offer, and what the AI Assistant is told it has.
3
+ *
4
+ * ONE inventory, read by three callers that used to disagree or not know at all:
5
+ *
6
+ * - the suggestion popup in any prompt field (@ for agents and skills, / for commands),
7
+ * - the Ace completer on the New screen form,
8
+ * - the assistant's own system prompt, which never listed these at all — so it could
9
+ * not use a skill it had, and would improvise instead of invoking one.
10
+ *
11
+ * The DESCRIPTION is why this is not just a directory listing. Claude Code chooses an
12
+ * agent or a skill by its frontmatter `description`, and a picker that shows only names
13
+ * makes the reader guess between `webship-workspace-build` and `webship-workspace-backups`.
14
+ * The same line is what lets the assistant pick correctly, so both read it from the file
15
+ * rather than from a hand-kept table that would drift.
16
+ *
17
+ * Three places hold items, and precedence runs the same way Claude Code resolves them:
18
+ * a project's own .claude/ wins over ~/.claude, which wins over the workspace draft
19
+ * folders (ai-agents/, ai-skills/, ai-commands/, ai-prompts/). `where` says which, so
20
+ * the list can show it and the assistant knows whether an item is actually installed.
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const { ROOT } = require('./workspaces');
27
+
28
+ /**
29
+ * The workspace tooling itself: `webship-workspace` and its skills (-build, -backups,
30
+ * -graphify, -dashboard, -authoring, -site-templates, -upsun, -linux-install).
31
+ *
32
+ * These are BUILT IN to the AI Assistant — it already operates this workspace, and its
33
+ * system prompt carries these rules whether anybody names them or not. So offering them
34
+ * in the picker is noise at best and misleading at worst: it implies that building a
35
+ * project needs you to remember `@webship-workspace-build` first, when asking for a build
36
+ * is enough. Nine rows out of 135, and they crowd the top of the list because they share
37
+ * a prefix with the product's own name.
38
+ *
39
+ * They stay in the inventory — the assistant is still told it has them, so it can say
40
+ * which one it is following — and are filtered out of the SUGGESTIONS only.
41
+ */
42
+ const BUILT_IN = /^webship-workspace(-|$)/;
43
+
44
+ // Kind -> which trigger character offers it.
45
+ //
46
+ // This mirrors the CLI rather than inventing a scheme: in Claude Code a slash command is
47
+ // typed as `/name`, and an agent is named in prose. Skills appear under BOTH here on
48
+ // purpose — a skill is a capability you name (@) and also the thing you most often want
49
+ // to invoke directly (/) — and prompts under neither, because a prompt is a whole
50
+ // `claude …` launch command for a NEW session, so it cannot be dropped into a message
51
+ // that is already running. The New screen form offers those in its own template picker.
52
+ const TRIGGERS = {
53
+ agent: ['@'],
54
+ skill: ['@', '/'],
55
+ command: ['/'],
56
+ };
57
+
58
+ // A frontmatter description, as one line.
59
+ //
60
+ // Read from the head of the file only: a skill can be hundreds of lines and none of the
61
+ // rest is frontmatter. The value may be folded over several lines in YAML, so a
62
+ // continuation (indented, no `key:` of its own) is joined back on.
63
+ const HEAD_BYTES = 4096;
64
+ function descriptionOf(file) {
65
+ let head = '';
66
+ try {
67
+ const fd = fs.openSync(file, 'r');
68
+ const buf = Buffer.alloc(HEAD_BYTES);
69
+ const read = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
70
+ fs.closeSync(fd);
71
+ head = buf.slice(0, read).toString('utf8');
72
+ } catch (_) { return ''; }
73
+ if (!/^---\s*$/m.test(head.split('\n')[0] || '')) return '';
74
+ const lines = head.split('\n').slice(1);
75
+ let out = '';
76
+ for (let i = 0; i < lines.length; i += 1) {
77
+ const line = lines[i];
78
+ if (/^---\s*$/.test(line)) break;
79
+ const m = line.match(/^description:\s*(.*)$/);
80
+ if (!m) continue;
81
+ out = m[1].trim().replace(/^["']|["']$/g, '');
82
+ // A folded value: following lines that are indented and are not themselves a key.
83
+ for (let j = i + 1; j < lines.length; j += 1) {
84
+ if (/^---\s*$/.test(lines[j])) break;
85
+ if (/^\s+\S/.test(lines[j]) && !/^\s*[a-zA-Z_-]+:/.test(lines[j])) {
86
+ out += ` ${lines[j].trim()}`;
87
+ } else break;
88
+ }
89
+ break;
90
+ }
91
+ return out.replace(/\s+/g, ' ').trim();
92
+ }
93
+
94
+ /** Items of one kind in one directory. Skills are directories with a SKILL.md. */
95
+ function readKind(dir, kind, where, out) {
96
+ let entries = [];
97
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
98
+ for (const e of entries) {
99
+ let name = '';
100
+ let file = '';
101
+ if (kind === 'skill') {
102
+ if (!e.isDirectory()) continue;
103
+ file = path.join(dir, e.name, 'SKILL.md');
104
+ if (!fs.existsSync(file)) continue;
105
+ name = e.name;
106
+ } else {
107
+ if (!e.isFile() || !e.name.endsWith('.md')) continue;
108
+ // A folder's own documentation is not an item in it.
109
+ if (['README.md', 'AGENTS.md', 'CLAUDE.md'].includes(e.name)) continue;
110
+ name = e.name.replace(/\.md$/, '');
111
+ file = path.join(dir, e.name);
112
+ }
113
+ const key = `${kind}:${name}`;
114
+ // First writer wins, and callers add in precedence order: a project's own copy of an
115
+ // item is the one that would actually run there, so it must not be overwritten by
116
+ // the global one found later.
117
+ if (out.has(key)) continue;
118
+ out.set(key, { name, kind, where, file, description: descriptionOf(file),
119
+ triggers: TRIGGERS[kind] || [], builtIn: BUILT_IN.test(name) });
120
+ }
121
+ }
122
+
123
+ // The filesystem is walked per request otherwise, and a keystroke in the composer asks
124
+ // for this list. Short enough that installing a skill shows up while you are looking.
125
+ const TTL = 5000;
126
+ const _cache = new Map();
127
+
128
+ /**
129
+ * Everything "@" and "/" can offer here.
130
+ * @param {string} [workspace] the workspace key, when a page is scoped to one
131
+ * @param {string} [project] a project inside it, whose own .claude/ takes precedence
132
+ */
133
+ function aiInventory(workspace, project) {
134
+ const ck = `${workspace || ''}/${project || ''}`;
135
+ const hit = _cache.get(ck);
136
+ if (hit && Date.now() - hit.at < TTL) return hit.items;
137
+
138
+ const home = process.env.HOME || '';
139
+ const items = new Map();
140
+
141
+ // Precedence, highest first.
142
+ if (workspace && project) {
143
+ const pDir = path.join(ROOT, workspace, project, '.claude');
144
+ readKind(path.join(pDir, 'agents'), 'agent', 'this project', items);
145
+ readKind(path.join(pDir, 'skills'), 'skill', 'this project', items);
146
+ readKind(path.join(pDir, 'commands'), 'command', 'this project', items);
147
+ }
148
+ readKind(path.join(home, '.claude', 'agents'), 'agent', 'installed', items);
149
+ readKind(path.join(home, '.claude', 'skills'), 'skill', 'installed', items);
150
+ readKind(path.join(home, '.claude', 'commands'), 'command', 'installed', items);
151
+ // The workspace drafts. Anything only here is NOT loaded by a session yet, which the
152
+ // label has to say — otherwise a draft reads as available and silently does nothing.
153
+ readKind(path.join(ROOT, 'ai-agents'), 'agent', 'workspace draft', items);
154
+ readKind(path.join(ROOT, 'ai-skills'), 'skill', 'workspace draft', items);
155
+ readKind(path.join(ROOT, 'ai-commands'), 'command', 'workspace draft', items);
156
+
157
+ const list = [...items.values()].sort((a, b) => a.name.localeCompare(b.name));
158
+ _cache.set(ck, { at: Date.now(), items: list });
159
+ return list;
160
+ }
161
+
162
+ /** Just the items a trigger character offers, optionally filtered by what is typed. */
163
+ function suggestFor(trigger, typed, workspace, project) {
164
+ const q = String(typed || '').toLowerCase();
165
+ return aiInventory(workspace, project)
166
+ // The assistant's own tooling is not something to pick — see BUILT_IN above.
167
+ .filter((i) => !i.builtIn)
168
+ .filter((i) => i.triggers.includes(trigger))
169
+ .filter((i) => !q || i.name.toLowerCase().includes(q)
170
+ || (i.description || '').toLowerCase().includes(q))
171
+ .sort((a, b) => {
172
+ // What the user is typing, first: a name that STARTS with it beats one that
173
+ // merely contains it, and a description match comes last.
174
+ const rank = (i) => (i.name.toLowerCase().startsWith(q) ? 0
175
+ : i.name.toLowerCase().includes(q) ? 1 : 2)
176
+ // then by how close to hand it is
177
+ + (i.where === 'this project' ? 0 : i.where === 'installed' ? 0.1 : 0.2);
178
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
179
+ });
180
+ }
181
+
182
+ module.exports = { aiInventory, suggestFor, descriptionOf, TRIGGERS, BUILT_IN };
package/ai-items.js ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The AI-items domain: agents, skills and prompts, in their three places —
3
+ * this workspace's folders (drafts), ~/.claude (what a session loads) and the
4
+ * shared repo (what the team has) — plus the git-blob comparison that says
5
+ * when the repo's copy differs from the folder's. The handlers live in
6
+ * actions/ai-items.js; the rendering in server.js imports the same primitives,
7
+ * so the buttons and the CLI can never disagree about where things are.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const { loadWorkspaces, workspaceDir, sourceOf } = require('./workspaces');
14
+ const { repoTree, invalidateRepoTree, gitBlobSha } = require('./repo-blobs');
15
+
16
+ /* ---------------- file-item workspaces (agents, skills, prompts, docs) --- */
17
+
18
+ // Where each editable kind installs into the live Claude Code CLI setup.
19
+ const INSTALL_TARGETS = {
20
+ 'ai-agents': path.join(process.env.HOME, '.claude', 'agents'),
21
+ 'ai-skills': path.join(process.env.HOME, '.claude', 'skills'),
22
+ // Commands and prompts are two kinds that share one destination: a command is a
23
+ // $ARGUMENTS spec typed mid-session, a prompt is a whole `claude …` launch command
24
+ // for a new session. Installing one writes over the other where names collide.
25
+ 'ai-commands': path.join(process.env.HOME, '.claude', 'commands'),
26
+ 'ai-prompts': path.join(process.env.HOME, '.claude', 'commands'),
27
+ };
28
+
29
+ // Agents, skills and prompts exist in three places: this workspace folder
30
+ // (drafts), ~/.claude (what a session loads) and webship/ai-agents (what
31
+ // the team has). The item lists show all three, merged by name, so it is
32
+ // obvious what is only local, only installed, or only in the repo.
33
+ const AI_ITEMS_REPO = () => process.env.AI_ITEMS_REPO || sourceOf('ai_items', 'repo', 'webship/ai-agents');
34
+ const AI_ITEMS_REF = () => process.env.AI_ITEMS_REF || sourceOf('ai_items', 'ref', 'main');
35
+ // A prompt is the whole `claude …` launch command for a NEW session, so the only way
36
+ // to use one is to start a session with it — the dashboard's New screen form, or the
37
+ // CLI by hand. It is not a slash command, so nothing of a prompt's syncs into ~/.claude
38
+ // or a project's .claude/. It keeps its INSTALL_TARGETS entry only so listClaudeItems
39
+ // can still SHOW what a machine already has there.
40
+ // AI Commands is the kind that DOES install: same destination, different shape.
41
+ const NO_CLAUDE_SYNC = new Set(['ai-prompts']);
42
+
43
+ // Where each kind sits IN THE REPO. Prompts are the one kind whose directory name
44
+ // differs from the CLI's: webship/ai-agents keeps them at the top level, in
45
+ // prompts/, beside the agents and skills they invoke.
46
+ const AI_ITEMS_REPO_PATHS = { 'ai-agents': '.claude/agents', 'ai-skills': '.claude/skills',
47
+ 'ai-commands': '.claude/commands', 'ai-prompts': 'prompts' };
48
+ // Where each kind installs INSIDE A PROJECT — always the project's own .claude/,
49
+ // which is what Claude Code reads there. Not the same map as above: a project's
50
+ // prompts go to .claude/commands, never to a prompts/ directory of its own.
51
+ const AI_ITEMS_PROJECT_PATHS = { 'ai-agents': '.claude/agents', 'ai-skills': '.claude/skills',
52
+ 'ai-commands': '.claude/commands', 'ai-prompts': '.claude/commands' };
53
+
54
+ // What ~/.claude currently has, by kind.
55
+ function listClaudeItems(key) {
56
+ const dir = INSTALL_TARGETS[key];
57
+ if (!dir) return new Set();
58
+ try {
59
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
60
+ if (key === 'ai-skills') {
61
+ return new Set(entries.filter((e) => e.isDirectory()
62
+ && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))).map((e) => e.name));
63
+ }
64
+ return new Set(entries.filter((e) => e.isFile() && e.name.endsWith('.md')
65
+ && !['README.md', 'AGENTS.md', 'CLAUDE.md'].includes(e.name))
66
+ .map((e) => e.name.replace(/\.md$/, '')));
67
+ } catch (_) { return new Set(); }
68
+ }
69
+
70
+ // Blob shas for every shared item, grouped by name, from the ONE cached recursive
71
+ // Trees call repo-blobs makes per repo@ref — the same identity git itself uses, so
72
+ // comparing them against locally computed blob shas answers "does the repo's copy
73
+ // differ from this folder's?" without fetching a single file body. `null` means
74
+ // the repo was unreachable (an unauthenticated container), which the UI states
75
+ // rather than implying the repo has nothing.
76
+ async function repoItemBlobs(key) {
77
+ if (!AI_ITEMS_REPO_PATHS[key]) return null;
78
+ const tree = await repoTree(AI_ITEMS_REPO(), AI_ITEMS_REF());
79
+ if (!tree) return null;
80
+ const prefix = `${AI_ITEMS_REPO_PATHS[key]}/`;
81
+ const map = new Map(); // item name -> Map<path-inside-item, blob sha>
82
+ for (const [p, sha] of tree) {
83
+ if (!p.startsWith(prefix)) continue;
84
+ const rest = p.slice(prefix.length);
85
+ let name; let inner;
86
+ if (key === 'ai-skills') {
87
+ const slash = rest.indexOf('/');
88
+ if (slash < 1) continue;
89
+ name = rest.slice(0, slash);
90
+ inner = rest.slice(slash + 1);
91
+ } else {
92
+ if (!rest.endsWith('.md') || rest.includes('/')) continue;
93
+ name = rest.slice(0, -3);
94
+ inner = '';
95
+ }
96
+ if (['README', 'AGENTS', 'CLAUDE', 'RULES'].includes(name)) continue;
97
+ if (!map.has(name)) map.set(name, new Map());
98
+ map.get(name).set(inner, sha);
99
+ }
100
+ return map;
101
+ }
102
+
103
+ // The names alone — the same tree, so a page listing names AND comparing blobs
104
+ // still costs one network call, not two.
105
+ async function listRepoItems(key) {
106
+ const blobs = await repoItemBlobs(key);
107
+ return blobs === null ? null : new Set(blobs.keys());
108
+ }
109
+
110
+ // Does the repo's copy of one item differ from this folder's? null = not comparable
111
+ // (not in the repo, or the tree was unreachable).
112
+ function repoDiffers(key, name, repoBlobs) {
113
+ if (!repoBlobs || !repoBlobs.has(name)) return null;
114
+ const theirs = repoBlobs.get(name);
115
+ const dir = workspaceDir(key);
116
+ try {
117
+ if (key !== 'ai-skills') {
118
+ return theirs.get('') !== gitBlobSha(path.join(dir, `${name}.md`));
119
+ }
120
+ // A skill is a directory: same files with the same blob shas, or it differs.
121
+ const base = path.join(dir, name);
122
+ const mine = new Map();
123
+ const walk = (d, rel) => {
124
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
125
+ if (e.name === '.git') continue;
126
+ const full = path.join(d, e.name);
127
+ const r = rel ? `${rel}/${e.name}` : e.name;
128
+ if (e.isDirectory()) walk(full, r);
129
+ else if (e.isFile()) mine.set(r, gitBlobSha(full));
130
+ }
131
+ };
132
+ walk(base, '');
133
+ if (mine.size !== theirs.size) return true;
134
+ for (const [rel, sha] of theirs) if (mine.get(rel) !== sha) return true;
135
+ return false;
136
+ } catch (_) {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ const ITEM_TEMPLATES = {
142
+ agents: (name) => `---
143
+ name: ${name}
144
+ description: Use this agent to <when to invoke it>.
145
+ tools:
146
+ - Bash
147
+ - Read
148
+ - Glob
149
+ - Grep
150
+ ---
151
+
152
+ # ${name}
153
+
154
+ You are the specialist agent for <what this agent does>.
155
+
156
+ ## Instructions
157
+
158
+ - <how to work>
159
+ `,
160
+ skills: (name) => `---
161
+ name: ${name}
162
+ description: <what this skill does and when to use it>
163
+ ---
164
+
165
+ # ${name}
166
+
167
+ ## Instructions
168
+
169
+ - <steps the skill follows>
170
+ `,
171
+ prompts: (name) => `<Write the reusable prompt here — installing it makes it available in Claude Code as /${name}>
172
+ `,
173
+ docs: (name) => `# ${name}
174
+
175
+ <Write the document here — Markdown; use "Make PDF" to render a PDF next to it.>
176
+ `,
177
+ };
178
+
179
+ // The file that holds an item's content, given its display name.
180
+ function itemFile(key, name) {
181
+ const dir = workspaceDir(key);
182
+ if (key === 'ai-skills') return path.join(dir, name, 'SKILL.md');
183
+ return path.join(dir, `${name}.md`);
184
+ }
185
+
186
+ // Where an install can land besides the global ~/.claude: any real project, so a session started
187
+ // under it (and only there) sees the item. Site workspaces only — a "project" in `docs/` or
188
+ // `agents/` itself would be another item, not something Claude Code scopes to.
189
+ function installTargetWorkspaces() {
190
+ return Object.values(loadWorkspaces()).filter((w) => w.kind !== 'files');
191
+ }
192
+
193
+ // Every write toward the repo or the folders makes the cached tree stale.
194
+ function invalidateRepoCaches() {
195
+ invalidateRepoTree(AI_ITEMS_REPO());
196
+ }
197
+
198
+ module.exports = {
199
+ INSTALL_TARGETS, NO_CLAUDE_SYNC, AI_ITEMS_REPO, AI_ITEMS_REF, AI_ITEMS_REPO_PATHS, AI_ITEMS_PROJECT_PATHS,
200
+ listClaudeItems, listRepoItems, repoItemBlobs, gitBlobSha, repoDiffers,
201
+ ITEM_TEMPLATES, itemFile, installTargetWorkspaces, invalidateRepoCaches,
202
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Who the assistant's avatar can be, and which voices it can speak in.
3
+ *
4
+ * Avatars. Only the ones named in settings.yml `assistant.avatars:` are offered, so a machine shows
5
+ * what its owner chose and nothing else. `druplicon` ships with the dashboard; any other name is looked
6
+ * up in the avatars workspace (`<root>/avatars/<project>/`): a character file there
7
+ * (`characters/<name>.json` or `characters/osa-<name>.json`) gives its model, display name and credit,
8
+ * and a bare model (`public/models/<name>.vrm`) is enough on its own. The models stay where they are and
9
+ * are served from there, never copied into this repository: their licences are their own, and some
10
+ * (fan-made ones) are for personal use only.
11
+ *
12
+ * Voices. Besides the browser's own voices, the ElevenLabs voices of the account in settings.yml
13
+ * `elevenlabs:`, once a real key is there. Listing them is free; speaking with one spends credits, which
14
+ * is why it happens only when someone picks an ElevenLabs voice. The key never leaves this server: the
15
+ * page posts the words and gets the audio back. What was spoken is kept in `.voice-cache/`, so a
16
+ * sentence heard again costs nothing.
17
+ */
18
+
19
+ const crypto = require('crypto');
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const DEFAULT_MODEL = 'eleven_multilingual_v2';
24
+ const CACHE_DIR = path.join(__dirname, '.voice-cache');
25
+ const CACHE_MAX = 400;
26
+ const MAX_TEXT = 600;
27
+ const BASES = {
28
+ us: 'https://api.us.elevenlabs.io',
29
+ eu: 'https://api.eu.residency.elevenlabs.io',
30
+ in: 'https://api.in.residency.elevenlabs.io',
31
+ sg: 'https://api.sg.residency.elevenlabs.io',
32
+ };
33
+
34
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
35
+ // The avatars that ship with the dashboard, in public/avatar/.
36
+ const BUILT_IN = {
37
+ druplicon: { name: 'Druplicon', model: '/avatar/druplicon.vrm',
38
+ credit: 'Druplicon, the Drupal drop, built in code. Druplicon is licensed under the GPL; Drupal is a registered trademark of Dries Buytaert.' },
39
+ };
40
+ const titleOf = (id) => id.replace(/^osa-/, '').split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
41
+
42
+ /** The set of moves an avatar does beyond the shared ones (public/assistant-avatar-moves.js), or ''. */
43
+ const moveSetOf = (id) => (/^labiba/.test(String(id || '')) ? 'labiba' : '');
44
+
45
+ /** The hair colour an avatar starts with (settings.yml assistant.avatar_hair.<id>), as #rrggbb, or ''. */
46
+ function hairOf(settings, id) {
47
+ const v = String(((settings.assistant || {}).avatar_hair || {})[id] || '').trim().replace(/^['"]|['"]$/g, '');
48
+ return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '';
49
+ }
50
+
51
+ /**
52
+ * The Claude Code agent an avatar brings (settings.yml assistant.avatar_agents.<id>), when it is
53
+ * installed for this user; '' keeps the assistant's own agent.
54
+ */
55
+ function agentOf(settings, id, home = process.env.HOME || '') {
56
+ const name = String(((settings.assistant || {}).avatar_agents || {})[id] || '').trim();
57
+ return /^[a-z0-9][a-z0-9-]{0,62}$/.test(name) && fs.existsSync(path.join(home, '.claude', 'agents', `${name}.md`)) ? name : '';
58
+ }
59
+
60
+ /** The avatars settings.yml names, with what the page needs to show each one. Unknown names are left out. */
61
+ function avatarCast(settings = {}, root = '') {
62
+ const a = settings.assistant || {};
63
+ const named = Array.isArray(a.avatars) && a.avatars.length ? a.avatars : ['druplicon'];
64
+ const out = [];
65
+ for (const raw of named) {
66
+ const id = String(raw || '').trim().toLowerCase();
67
+ if (!NAME_RE.test(id) || id === 'none' || out.some((x) => x.id === id)) continue;
68
+ const more = { moves: moveSetOf(id), hair: hairOf(settings, id) };
69
+ // The model's link carries its file's time, so a rebuilt model is fetched at once rather than after
70
+ // the hour a browser may keep it.
71
+ if (BUILT_IN[id]) { out.push({ id, ...BUILT_IN[id], model: `${BUILT_IN[id].model}?v=${stamp(path.join(__dirname, 'public', BUILT_IN[id].model))}`, ...more }); continue; }
72
+ const found = findAvatar(id, root);
73
+ if (found) out.push({ id, name: found.name, model: `/avatar/model/${id}.vrm?v=${stamp(found.file)}`, credit: found.credit, ...more });
74
+ }
75
+ return out;
76
+ }
77
+
78
+ const stamp = (file) => { try { return Math.round(fs.statSync(file).mtimeMs).toString(36); } catch (_) { return '0'; } };
79
+
80
+ /** The model file of an avatar, inside `<root>/avatars`, or null. */
81
+ function findAvatar(id, root) {
82
+ if (!NAME_RE.test(id) || !root) return null;
83
+ const base = path.join(root, 'avatars');
84
+ let projects = [];
85
+ try { projects = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name).sort(); } catch (_) { return null; }
86
+ const inside = (file) => {
87
+ try {
88
+ const real = fs.realpathSync(file);
89
+ return real.startsWith(fs.realpathSync(base) + path.sep) && /\.vrm$/i.test(real) && fs.statSync(real).isFile() ? real : null;
90
+ } catch (_) { return null; }
91
+ };
92
+ for (const p of projects) {
93
+ for (const stem of [id, `osa-${id}`]) {
94
+ let c = null;
95
+ try { c = JSON.parse(fs.readFileSync(path.join(base, p, 'characters', `${stem}.json`), 'utf8')); } catch (_) { continue; }
96
+ const rel = String((c && c.model) || '').replace(/^\/+/, '');
97
+ if (!/^models\/[\w.-]+\.vrm$/i.test(rel)) continue;
98
+ const file = inside(path.join(base, p, 'public', rel));
99
+ if (file) return { file, name: String(c.name || titleOf(id)).slice(0, 40), credit: String(c.credit || '').slice(0, 400) };
100
+ }
101
+ }
102
+ for (const p of projects) {
103
+ for (const stem of [id, `osa-${id}`]) {
104
+ const file = inside(path.join(base, p, 'public', 'models', `${stem}.vrm`));
105
+ if (file) return { file, name: titleOf(id), credit: `From the ${p} avatar project; its README states the model's licence.` };
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /** GET /avatar/model/<id>.vrm: only a model of an avatar settings.yml names. */
112
+ function serveAvatarModel(res, id, settings, root) {
113
+ const listed = avatarCast(settings, root).some((a) => a.id === id && !BUILT_IN[a.id]);
114
+ const found = listed ? findAvatar(id, root) : null;
115
+ if (!found) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('No such avatar.'); }
116
+ res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' });
117
+ fs.createReadStream(found.file).pipe(res);
118
+ }
119
+
120
+ // ---- ElevenLabs ----------------------------------------------------------------------------------
121
+
122
+ /** The account's key from settings.yml, or '' while it is a placeholder or missing. */
123
+ function elevenKey(settings = {}) {
124
+ const k = String((settings.elevenlabs && settings.elevenlabs.api_key) || '').trim();
125
+ return /^[A-Za-z0-9_.-]{20,}$/.test(k) && !/change/i.test(k) ? k : '';
126
+ }
127
+ const elevenBase = (settings = {}) => process.env.ELEVENLABS_API_BASE
128
+ || BASES[String((settings.elevenlabs && settings.elevenlabs.region) || '').toLowerCase()] || 'https://api.elevenlabs.io';
129
+
130
+ let voicesMemo = { at: 0, sig: '', list: null };
131
+
132
+ /** The account's voices, `{ id, name, label }`, kept ten minutes. Free: it lists, it does not speak. */
133
+ async function elevenVoices(settings = {}, { fetchImpl = fetch } = {}) {
134
+ const key = elevenKey(settings);
135
+ if (!key) return { connected: false, voices: [] };
136
+ const sig = crypto.createHash('sha256').update(key + elevenBase(settings)).digest('hex');
137
+ if (voicesMemo.sig === sig && voicesMemo.list && Date.now() - voicesMemo.at < 600000) return { connected: true, voices: voicesMemo.list };
138
+ const r = await fetchImpl(`${elevenBase(settings)}/v2/voices?page_size=100`, { headers: { 'xi-api-key': key }, signal: AbortSignal.timeout(10000) });
139
+ if (!r.ok) return { connected: true, voices: [], error: r.status === 401 ? 'ElevenLabs rejected the key.' : `ElevenLabs answered ${r.status}.` };
140
+ const body = await r.json();
141
+ const list = (body.voices || []).filter((v) => /^[A-Za-z0-9]{16,32}$/.test(String(v.voice_id || ''))).map((v) => {
142
+ const l = v.labels || {};
143
+ return { id: v.voice_id, name: String(v.name || v.voice_id).slice(0, 60), label: [l.accent, l.gender, l.age].filter(Boolean).join(', ').slice(0, 60) };
144
+ });
145
+ voicesMemo = { at: Date.now(), sig, list };
146
+ return { connected: true, voices: list };
147
+ }
148
+
149
+ /** The audio of `text` in an ElevenLabs voice, as an mp3 Buffer; from the cache when it was spoken before. */
150
+ async function elevenSpeak(settings = {}, { voice, text }, { fetchImpl = fetch, cacheDir = CACHE_DIR } = {}) {
151
+ const key = elevenKey(settings);
152
+ if (!key) throw Object.assign(new Error('ElevenLabs is not connected.'), { status: 409 });
153
+ const id = String(voice || '');
154
+ const words = String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
155
+ if (!/^[A-Za-z0-9]{16,32}$/.test(id) || !words) throw Object.assign(new Error('A voice and some words, please.'), { status: 400 });
156
+ const model = String((settings.elevenlabs && (settings.elevenlabs.model_id || settings.elevenlabs.model)) || DEFAULT_MODEL).replace(/[^\w.-]/g, '') || DEFAULT_MODEL;
157
+ const file = path.join(cacheDir, `${crypto.createHash('sha256').update(`${id}\n${model}\n${words}`).digest('hex')}.mp3`);
158
+ try { return fs.readFileSync(file); } catch (_) { /* not spoken yet */ }
159
+ const r = await fetchImpl(`${elevenBase(settings)}/v1/text-to-speech/${id}?output_format=mp3_44100_128`, {
160
+ method: 'POST', signal: AbortSignal.timeout(30000),
161
+ headers: { 'xi-api-key': key, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
162
+ body: JSON.stringify({ text: words, model_id: model }),
163
+ });
164
+ if (!r.ok) throw Object.assign(new Error(r.status === 401 ? 'ElevenLabs rejected the key.' : `ElevenLabs answered ${r.status}.`), { status: 502 });
165
+ const audio = Buffer.from(await r.arrayBuffer());
166
+ try {
167
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
168
+ fs.writeFileSync(file, audio, { mode: 0o600 });
169
+ const kept = fs.readdirSync(cacheDir).filter((f) => f.endsWith('.mp3'))
170
+ .map((f) => ({ f, t: fs.statSync(path.join(cacheDir, f)).mtimeMs })).sort((a, b) => b.t - a.t);
171
+ for (const old of kept.slice(CACHE_MAX)) fs.unlinkSync(path.join(cacheDir, old.f));
172
+ } catch (_) { /* spoken, just not kept */ }
173
+ return audio;
174
+ }
175
+
176
+ module.exports = { BUILT_IN, avatarCast, moveSetOf, hairOf, agentOf, findAvatar, serveAvatarModel, elevenKey, elevenVoices, elevenSpeak };