@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
package/assistant.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the assistant answers, separated from how the answer is delivered.
|
|
3
|
+
*
|
|
4
|
+
* Two callers ask the same question: the HTMX pane posts a form and swaps the reply in, and the
|
|
5
|
+
* deep-chat component posts JSON and renders it itself. Both need the same thing — the reply, and
|
|
6
|
+
* the directives the agent ended with — so this returns those and neither owns the other's plumbing.
|
|
7
|
+
*
|
|
8
|
+
* The assistant runs in agent mode: it can actually drive the workspace tooling (Bash and the read
|
|
9
|
+
* tools, inside this container where ~/workspace, ddev and docker all are) and then steer the
|
|
10
|
+
* interface with NAVIGATE / OPEN / REFRESH, which the browser acts on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { spawn } = require('child_process');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
const { esc } = require('./html');
|
|
19
|
+
const {
|
|
20
|
+
ROOT, CONFIG_DIR, loadSettings, loadWorkspaces, isValidWorkspace, workspaceDir, hubDomain,
|
|
21
|
+
findBuilderScripts, listBackups, commandFlags,
|
|
22
|
+
} = require('./workspaces');
|
|
23
|
+
const { run, listProjects } = require('./jobs');
|
|
24
|
+
const { hasGraph } = require('./graphs');
|
|
25
|
+
const { listSettingsFiles } = require('./settings-file');
|
|
26
|
+
const { HOME_URL, wsUrl } = require('./urls');
|
|
27
|
+
const { aiInventory } = require('./ai-inventory');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the page the question came from currently shows.
|
|
31
|
+
*
|
|
32
|
+
* Computed fresh per message, and separated from the reply so it can be checked without spawning
|
|
33
|
+
* an agent: every branch here reaches into the rest of the app, and a name that is not imported
|
|
34
|
+
* fails only when someone happens to ask from that page. That is exactly what happened — the
|
|
35
|
+
* settings and workspace branches threw ReferenceError for `listSettingsFiles`, `CONFIG_DIR`,
|
|
36
|
+
* `findBuilderScripts` and `listBackups` after the module split, so the assistant answered on the
|
|
37
|
+
* home page and returned HTTP 500 everywhere else.
|
|
38
|
+
*/
|
|
39
|
+
// The AI Context a workspace carries: the folder's AGENTS.md (its own working
|
|
40
|
+
// instructions) and each command's real flag contract from its arg file. This
|
|
41
|
+
// is what lets the assistant run the right command with the right flags on the
|
|
42
|
+
// user's behalf instead of guessing at them.
|
|
43
|
+
function workspaceAiContext(key) {
|
|
44
|
+
const meta = loadWorkspaces()[key];
|
|
45
|
+
if (!meta) return '';
|
|
46
|
+
const parts = [];
|
|
47
|
+
try {
|
|
48
|
+
const agentsMd = fs.readFileSync(path.join(meta.dir, 'AGENTS.md'), 'utf8').slice(0, 3000);
|
|
49
|
+
parts.push(`This workspace's own AGENTS.md (follow it — it names the webship-workspace* skills that apply here; when one covers the task, invoke it with the Skill tool BEFORE improvising with Bash):\n${agentsMd}`);
|
|
50
|
+
} catch (_) { /* no AGENTS.md here */ }
|
|
51
|
+
let scripts = [];
|
|
52
|
+
try {
|
|
53
|
+
scripts = fs.readdirSync(meta.dir).filter((f) => /^cmd-.*\.sh$/.test(f)).sort();
|
|
54
|
+
} catch (_) { /* no folder, no commands */ }
|
|
55
|
+
const flagLines = scripts.map((scriptName) => {
|
|
56
|
+
const flags = commandFlags(meta.dir, scriptName)
|
|
57
|
+
.map((f) => `${f.long} (${f.short}) ${f.help}`).join(' | ');
|
|
58
|
+
return flags ? ` ${scriptName}: ${flags}` : ` ${scriptName}`;
|
|
59
|
+
});
|
|
60
|
+
if (flagLines.length) {
|
|
61
|
+
parts.push('Command contract — every builder here and the flags it ACTUALLY takes '
|
|
62
|
+
+ '(--install is always opt-in: without it a build stops at a codebase for the browser installer):\n'
|
|
63
|
+
+ flagLines.join('\n'));
|
|
64
|
+
}
|
|
65
|
+
return parts.join('\n\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pageContextFor(ctx) {
|
|
69
|
+
let pageContext = 'The user is on the dashboard home page showing all workspace cards. '
|
|
70
|
+
+ 'Each workspace folder carries its own AI Context: an AGENTS.md with its working rules, and '
|
|
71
|
+
+ 'cmd-*.sh commands whose real flags come from core/scripts/args/. Read a workspace\'s AGENTS.md '
|
|
72
|
+
+ 'before running its commands on the user\'s behalf.';
|
|
73
|
+
// `ctx` is the page the question was asked from, so the answer can be about what is on it.
|
|
74
|
+
if (ctx === 'settings') {
|
|
75
|
+
const files = listSettingsFiles();
|
|
76
|
+
// Name the files and the KEYS, never a value: this page is the one place the
|
|
77
|
+
// real API keys and the account password live.
|
|
78
|
+
pageContext = `The user is on the Settings page (/settings), editing the YAML in ${CONFIG_DIR}. `
|
|
79
|
+
+ `Files: ${files.join(', ')}. Secret keys in settings.yml (pass, api_key, token) hold REAL `
|
|
80
|
+
+ 'credentials on this machine. NEVER print, echo, cat, grep or otherwise reveal the value of '
|
|
81
|
+
+ 'any secret key, and never copy one into a command line — describe whether it is set instead. '
|
|
82
|
+
+ 'The dashboard writes these files line by line to preserve comments, so prefer telling the '
|
|
83
|
+
+ 'user which field to change on this page over rewriting a file with Bash.';
|
|
84
|
+
}
|
|
85
|
+
const ctxMatch = ctx.match(/^(workspace|backups):([a-z0-9_-]+)$/);
|
|
86
|
+
if (ctxMatch && isValidWorkspace(ctxMatch[2])) {
|
|
87
|
+
const cKey = ctxMatch[2];
|
|
88
|
+
const cMeta = loadWorkspaces()[cKey];
|
|
89
|
+
if (ctxMatch[1] === 'workspace') {
|
|
90
|
+
const projs = listProjects(cMeta.dir);
|
|
91
|
+
// Name the projects that have a graph, so the assistant queries it instead of
|
|
92
|
+
// grepping — and so it knows the graph exists at all, which it cannot infer.
|
|
93
|
+
const graphed = projs.filter((pr) => hasGraph(cKey, pr, 'graph.json'));
|
|
94
|
+
pageContext = `The user is on the "${cMeta.label}" workspace page (/${cKey}, folder ${cMeta.dir}). Projects currently listed: ${projs.join(', ') || '(none)'} .\n${workspaceAiContext(cKey)}`
|
|
95
|
+
+ (graphed.length
|
|
96
|
+
? ` These projects have a graphify knowledge graph, kept in ${ROOT}/graphs/${cKey}/<project>/ rather than inside the project: ${graphed.join(', ')}. `
|
|
97
|
+
+ `For those, prefer \`graphify query "..." --graph ${ROOT}/graphs/${cKey}/<project>/graph.json\` (also explain, path, god-nodes) over grepping the tree, then read the files it points at. `
|
|
98
|
+
+ 'It maps PHP classes under src/, JS and SQL only — it does NOT map hooks in .module/.theme/.install, Twig, or any *.yml, so routing, services and plugin wiring are absent and a question about those comes back empty whether or not the answer exists. Rebuild it with `bash cmd-tools-graphify.sh <project>` after code changes; the graph is a snapshot and does not follow edits.'
|
|
99
|
+
: '');
|
|
100
|
+
} else {
|
|
101
|
+
pageContext = `The user is on the "${cMeta.label}" backups page (/${cKey}/backups). Backups currently listed: ${listBackups(cKey).map((b) => b.file).join(', ') || '(none)'}.`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return pageContext;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The id of a YouTube video link, or null. */
|
|
108
|
+
function youtubeId(link) {
|
|
109
|
+
try {
|
|
110
|
+
const u = new URL(link);
|
|
111
|
+
const host = u.hostname.replace(/^www\.|^m\./, '');
|
|
112
|
+
const id = host === 'youtu.be' ? u.pathname.slice(1) : host === 'youtube.com' ? (u.searchParams.get('v') || (u.pathname.match(/^\/(?:embed|shorts|live)\/([\w-]{11})/) || [])[1]) : null;
|
|
113
|
+
return /^[\w-]{11}$/.test(String(id || '')) ? id : null;
|
|
114
|
+
} catch (_) { return null; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The avatar's moves, as the assistant may write them: [wave] … in a reply (public/assistant-avatar.js).
|
|
118
|
+
const AVATAR_GESTURES = ['wave', 'nod', 'think', 'explain', 'point', 'idea', 'shrug', 'thumbsup', 'cheer', 'clap', 'crossarms', 'look', 'bye'];
|
|
119
|
+
// More moves for an avatar with a set of its own (public/assistant-avatar-moves.js), and what each is for.
|
|
120
|
+
const AVATAR_MOVE_SETS = {
|
|
121
|
+
clock: {
|
|
122
|
+
stand: 'fists on hips: ready', welcome: 'arms out, palms up: welcome, here it is', showcase: 'an arm swept out, palm up: look at this',
|
|
123
|
+
fistpump: 'a fist pumped: yes, it worked', me: 'a thumb at itself: leave it with me', sideeye: 'a sidelong look: really?',
|
|
124
|
+
wink: 'a wink and a thumbs up: got it', strut: 'a strut on the spot: on its way', tilt: 'a curious tilt', lean: 'leaning in to listen',
|
|
125
|
+
bounce: 'a bounce, pleased', spinhands: 'its hands race round the dial, delighted', tada: 'arms up, hands spinning: good news',
|
|
126
|
+
tick: 'ticking side to side while it waits', glance: 'a look over the shoulder', alert: 'a finger straight up: attention',
|
|
127
|
+
},
|
|
128
|
+
labiba: {
|
|
129
|
+
bow: 'a polite bow, to thank or welcome', shake: 'no', listen: 'while you take in a question', read: 'reading something through',
|
|
130
|
+
magnify: 'looking closely, reviewing', draw: 'drawing on the board', drawbig: 'a big diagram on the board', holdup: 'showing something',
|
|
131
|
+
proud: 'proud of a result', laugh: 'a light laugh', surprised: 'surprise', confused: 'puzzled', hips: 'ready, waiting',
|
|
132
|
+
raiseright: 'a raised hand, a point to make', raiseleft: 'a raised hand', tap: 'tapping a key', push: 'pushing a change',
|
|
133
|
+
swipeup: 'scrolling up', swipedown: 'scrolling down', globe: 'the world, languages', rocket: 'a launch, a release', fly: 'flying',
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
// Big moves, where the whole avatar travels the page (public/assistant-avatar-ui.js, BIG), and what each is for.
|
|
137
|
+
const AVATAR_BIG_MOVES = {
|
|
138
|
+
tour: 'a loop round the page and back, for a big moment or a welcome', dash: 'walks across the page and back',
|
|
139
|
+
jump: 'a big leap, for good news', zoom: 'grows to its biggest for a moment', present: 'walks to the middle, grows and explains',
|
|
140
|
+
spin: 'a full turn, for a finished job',
|
|
141
|
+
};
|
|
142
|
+
const AVATAR_PLACES = ['home', 'left', 'right', 'center', 'panel'];
|
|
143
|
+
const AVATAR_SIZES = ['small', 'medium', 'large', 'xl', 'huge', 'bigger', 'smaller'];
|
|
144
|
+
|
|
145
|
+
async function assistantReply(message, ctx, thread = null, { voice = false, avatar = false, avatarName = '', avatarId = '', page = null } = {}) {
|
|
146
|
+
const cast = require('./assistant-cast');
|
|
147
|
+
const moveSet = AVATAR_MOVE_SETS[cast.moveSetOf(avatarId)] || {};
|
|
148
|
+
const moves = [...AVATAR_GESTURES, ...Object.keys(AVATAR_BIG_MOVES), ...Object.keys(moveSet).filter((m) => !AVATAR_GESTURES.includes(m))];
|
|
149
|
+
// The avatar picked in the panel: a display name, nothing more, so it is cut down to letters and spaces.
|
|
150
|
+
const shownAs = String(avatarName || '').replace(/[^\p{L}\p{N} .'-]/gu, '').trim().slice(0, 40) || 'Druplicon';
|
|
151
|
+
|
|
152
|
+
// Agent mode: the assistant can actually run the workspace tooling
|
|
153
|
+
// (Bash + read tools inside this container, where ~/workspace, ddev and
|
|
154
|
+
// docker are all available) and steers the interface afterwards through
|
|
155
|
+
// NAVIGATE/OPEN/REFRESH directives that ui.js executes in the browser.
|
|
156
|
+
const workspaceNames = Object.values(loadWorkspaces())
|
|
157
|
+
.map((w) => `${w.key} (${w.subtitle})`)
|
|
158
|
+
.join('; ');
|
|
159
|
+
const pageContext = pageContextFor(ctx);
|
|
160
|
+
// Which workspace (and project, when the page is about one) the inventory and the work
|
|
161
|
+
// documents below resolve against — the same context string that titles the panel
|
|
162
|
+
// "Dev AI Assistant" and scopes the "@"/"/" suggestions, so all three agree.
|
|
163
|
+
const ctxScope = String(ctx || '').match(/^(?:workspace|backups):([a-z0-9_-]+)(?::([a-zA-Z0-9_-]+))?$/);
|
|
164
|
+
const ctxWs = ctxScope && isValidWorkspace(ctxScope[1]) ? ctxScope[1] : '';
|
|
165
|
+
const ctxProj = ctxWs && ctxScope[2] ? ctxScope[2] : '';
|
|
166
|
+
// Its memory: one folder (settings.yml assistant.memory), made on first use and private to this user.
|
|
167
|
+
const memoryDir = path.join(ROOT, require('./speech').assistantSettings(loadSettings()).memory);
|
|
168
|
+
try { fs.mkdirSync(memoryDir, { recursive: true, mode: 0o700 }); } catch (_) { /* reported by the agent if it cannot write */ }
|
|
169
|
+
const system = [
|
|
170
|
+
`You are the Webship Workspace AI Assistant embedded in the web dashboard at https://workspace.ddev.site, managing the Webship Workspace tooling rooted at ${ROOT}.`,
|
|
171
|
+
`You run inside the dashboard's container with Bash access: the whole workspace tree is at ${ROOT}, and the ddev + docker CLIs manage sibling DDEV projects.`,
|
|
172
|
+
`Workspaces (folders under ${ROOT}): ${workspaceNames}.`,
|
|
173
|
+
'The components workspace holds Drupal SDC components, React components, code components for Drupal Canvas, and HTMX and web components.',
|
|
174
|
+
`Default domain scheme (hub domain: ${hubDomain()}): each workspace has <workspace>.${hubDomain()} (its dashboard page), and every running project has https://<project>.<workspace>.${hubDomain()} (its real site) — prefer these hierarchical URLs in OPEN directives; the canonical https://<project>.ddev.site also works.`,
|
|
175
|
+
pageContext,
|
|
176
|
+
(() => {
|
|
177
|
+
// The Workspace's default language (settings.yml `language:`): answer in it unless spoken to in another.
|
|
178
|
+
const sp = require('./speech');
|
|
179
|
+
const lang = sp.workspaceLanguage(loadSettings());
|
|
180
|
+
return `The Workspace's language is ${sp.languageName(lang)} (${lang}): answer in it, unless the person writes to you in another language; then answer in theirs. Commands, paths and code stay as they are.`;
|
|
181
|
+
})(),
|
|
182
|
+
// What the activated plugins let the assistant reach (a tracker, a cloud), each with its own rules.
|
|
183
|
+
...require('./plugin-loader').pluginAssistantContext(),
|
|
184
|
+
// WHAT THIS MACHINE ACTUALLY HAS. Without this the assistant improvised: it would
|
|
185
|
+
// describe doing a job by hand that a skill on this very machine does properly, or
|
|
186
|
+
// name an agent that does not exist. Names only, with the descriptions left on disk —
|
|
187
|
+
// 87 agents and 42 skills with their descriptions is a prompt of its own, and the
|
|
188
|
+
// right move is to read the one file that matters when it matters.
|
|
189
|
+
(() => {
|
|
190
|
+
const inv = aiInventory(ctxWs, ctxProj);
|
|
191
|
+
if (!inv.length) return '';
|
|
192
|
+
// The workspace tooling is YOURS, not something the user picks. It is deliberately
|
|
193
|
+
// absent from the "@"/"/" suggestions — asking for a build should not require
|
|
194
|
+
// remembering `@webship-workspace-build` first — so it is stated here instead, as
|
|
195
|
+
// the rules this assistant already works by.
|
|
196
|
+
const mine = inv.filter((i) => i.builtIn);
|
|
197
|
+
const theirs = inv.filter((i) => !i.builtIn);
|
|
198
|
+
const of = (kind) => theirs.filter((i) => i.kind === kind);
|
|
199
|
+
const names = (kind) => of(kind).map((i) => i.name).join(', ');
|
|
200
|
+
return [
|
|
201
|
+
...(mine.length ? [
|
|
202
|
+
'YOUR OWN TOOLING, BUILT IN. You ARE the Webship Workspace assistant, so these are not',
|
|
203
|
+
'things the user has to name — they are the rules you already work by, and they are',
|
|
204
|
+
'deliberately absent from the "@" and "/" suggestions for that reason. Read the one that',
|
|
205
|
+
`covers the job and follow it: ${mine.map((i) => `${i.name} (${i.kind})`).join(', ')}.`,
|
|
206
|
+
'Say which you are following, and never ask the user to name one of these.',
|
|
207
|
+
] : []),
|
|
208
|
+
'WHAT THE USER CAN NAME. Installed on this machine and usable through the Claude Code CLI;',
|
|
209
|
+
'these are exactly what the "@" and "/" suggestions offer.',
|
|
210
|
+
`- Agents (${of('agent').length}), in ~/.claude/agents/<name>.md: ${names('agent')}`,
|
|
211
|
+
`- Skills (${of('skill').length}), in ~/.claude/skills/<name>/SKILL.md: ${names('skill')}`,
|
|
212
|
+
`- Commands (${of('command').length}), in ~/.claude/commands/<name>.md: ${names('command')}`,
|
|
213
|
+
'Read an item\'s frontmatter `description` before using it, and PREFER one over improvising:',
|
|
214
|
+
'a skill carries the steps, the flags and the traps for that job. When a task matches one, say',
|
|
215
|
+
'which you are following. The user can name them in the chat with "@<agent-or-skill>" and',
|
|
216
|
+
'"/<command-or-skill>", and those refer to exactly these.',
|
|
217
|
+
// Several names exist as BOTH an agent and a skill, and they are not the same thing.
|
|
218
|
+
// Guessing wrong means doing a whole job with the recording steps, or the reverse.
|
|
219
|
+
'Some names exist as BOTH an agent and a skill. They are not interchangeable: the agent drives',
|
|
220
|
+
'the whole job, the skill carries the steps for one part of it. The user may write the kind to',
|
|
221
|
+
'be explicit — @name (agent), @"name (agent)" and @name are all the same reference — and when',
|
|
222
|
+
'a name is ambiguous and unqualified, say which one you are using before you start.',
|
|
223
|
+
// A prompt naming three items and four folders is normal here, and the assistant has to
|
|
224
|
+
// understand that the folders are not one workspace.
|
|
225
|
+
'A REQUEST MAY CROSS WORKSPACES, and usually does: work in one folder, read a document from',
|
|
226
|
+
`${ROOT}/docs, save output into ${ROOT}/videos, open a project in ${ROOT}/test. Each folder is a`,
|
|
227
|
+
'workspace with its own AGENTS.md and its own rules; follow the rules of the folder each step',
|
|
228
|
+
'happens in, not only those of the page the question was asked from. When several items are',
|
|
229
|
+
'named in one message, use each for the part it covers and say which is doing what.',
|
|
230
|
+
].join('\n');
|
|
231
|
+
})(),
|
|
232
|
+
// THE WORK DOCUMENTS. The tooling's own rules live in files, not in this prompt, and they
|
|
233
|
+
// are the difference between a correct command and a plausible one — AGENTS.md is where a
|
|
234
|
+
// folder states its hard rules, and a project's CLAUDE.md is written by graphify.
|
|
235
|
+
(() => {
|
|
236
|
+
const lines = ['THE WORK DOCUMENTS — read the ones that cover what you are asked, before acting:',
|
|
237
|
+
`- ${ROOT}/CLAUDE.md — how the whole tooling fits together, and the hard rules (DDEV only, and the rest).`,
|
|
238
|
+
`- ${ROOT}/<workspace>/AGENTS.md — that folder explained to an agent: its commands, its rules, which`,
|
|
239
|
+
' agents and skills apply there. README.md beside it is the same folder for a person.',
|
|
240
|
+
`- ${ROOT}/docs/ — the shared documents workspace (guides, datasheets, decks, PDFs).`,
|
|
241
|
+
`- ${ROOT}/worklogs/ — dated session worklogs: what changed, what was learned, what was left unverified.`,
|
|
242
|
+
`- ${ROOT}/specs/ — specifications for work in progress.`];
|
|
243
|
+
if (ctxWs) {
|
|
244
|
+
const wsDir = path.join(ROOT, ctxWs);
|
|
245
|
+
const has = ['AGENTS.md', 'README.md', 'CLAUDE.md'].filter((n) => fs.existsSync(path.join(wsDir, n)));
|
|
246
|
+
if (has.length) lines.push(`This page's workspace carries: ${has.map((n) => `${wsDir}/${n}`).join(', ')}. Read its AGENTS.md before running its commands.`);
|
|
247
|
+
}
|
|
248
|
+
if (ctxWs && ctxProj) {
|
|
249
|
+
const pDir = path.join(ROOT, ctxWs, ctxProj);
|
|
250
|
+
const has = ['CLAUDE.md', 'AGENTS.md', 'README.md'].filter((n) => fs.existsSync(path.join(pDir, n)));
|
|
251
|
+
if (has.length) lines.push(`The project itself carries: ${has.map((n) => `${pDir}/${n}`).join(', ')} — a project's own rules win over the general ones.`);
|
|
252
|
+
}
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
})(),
|
|
255
|
+
'How to act:',
|
|
256
|
+
`- Inspect: ls ${ROOT}/<workspace> ; ddev list ; each builder script has a "# workspace-name:" header naming what it builds.`,
|
|
257
|
+
`- Build a new project: cd ${ROOT}/<workspace> && bash cmd-<...>-project.sh <project_name> --install`,
|
|
258
|
+
// ONE ddev start at a time — and it is no longer this prompt's job to remember.
|
|
259
|
+
// `ddev` on PATH in this container is core/scripts/ddev-shim/ddev, which takes the
|
|
260
|
+
// router lock for start/stop/restart/poweroff/delete and passes everything else
|
|
261
|
+
// straight through. The rule used to live here as "always write flock … ddev start",
|
|
262
|
+
// which held only as well as the model followed it and said nothing to a cmd-*.sh
|
|
263
|
+
// script or a person in a terminal.
|
|
264
|
+
`- Start/stop an existing project: cd ${ROOT}/<workspace>/<project> && ddev start -y (or ddev stop -y)`,
|
|
265
|
+
' These are SERIALISED for you: the workspace ships a ddev wrapper that holds a lock for',
|
|
266
|
+
' start, stop, restart, poweroff and delete, because each one recreates the ONE shared',
|
|
267
|
+
' ddev-router and two at a time can destroy it — taking every workspace URL down at once.',
|
|
268
|
+
' So run them one after another and never in parallel or in a background subshell; if one',
|
|
269
|
+
' seems to pause before starting, it is queued behind another and will say so.',
|
|
270
|
+
`- Backup: run the folder's cmd-tool*-backup-*.sh <project_name> from inside ${ROOT}/<workspace>.`,
|
|
271
|
+
`- Create/edit AI agents, skills, prompts, docs: write markdown files with Bash redirection — agents: ${ROOT}/agents/<name>.md (YAML frontmatter: name, description, tools), skills: ${ROOT}/skills/<name>/SKILL.md, prompts: ${ROOT}/prompts/<name>.md, docs: ${ROOT}/docs/<name>.md. Install into Claude Code by copying: agents → ~/.claude/agents/, skills → ~/.claude/skills/<name>/, prompts → ~/.claude/commands/.`,
|
|
272
|
+
`- Docs tooling: render PDF with: pandoc <doc>.md -o <doc>.pdf --pdf-engine=weasyprint ; render HTML with: pandoc <doc>.md -o <doc>.html --standalone ; capture a site screenshot with: chromium --headless --no-sandbox --window-size=1440,1200 --screenshot=${ROOT}/docs/<name>.png <url>`,
|
|
273
|
+
`- LONG WORK BELONGS IN A SCREEN SESSION. Anything that outlives one command — a build, an install,`,
|
|
274
|
+
` a suite, a sync, a Claude Code run — starts detached so it survives dashboard restarts and the user`,
|
|
275
|
+
` can watch it: cd ${ROOT}/<workspace> && bash cmd-tools-screen.sh <session-name> --run "<command>".`,
|
|
276
|
+
' Session names are lowercase and may hold digits, - _ . — name them for the work (build-webship11-demo2,',
|
|
277
|
+
' suite-browser, upsun-pull-website_starter). The output lands in both the session window and',
|
|
278
|
+
` ${ROOT}/.screens/<session>.log. List them with \`screen -ls\` or bash cmd-tools-screen.sh --list;`,
|
|
279
|
+
' follow one with --follow; stop one with --stop.',
|
|
280
|
+
'- The Screens page (/screens) is where the user manages them: it starts a new session in any workspace',
|
|
281
|
+
' folder or project (a shell, the Claude Code CLI, or a command), opens a REAL interactive terminal in',
|
|
282
|
+
' the browser for any session, follows its log, detaches other clients, sends Ctrl-C, kills it, and wipes',
|
|
283
|
+
' dead ones. After starting something long, say the session name and end with NAVIGATE:/screens so the',
|
|
284
|
+
' user lands where it can be watched.',
|
|
285
|
+
'- NEVER delete or remove anything unless the user explicitly asked for that in this exact message.',
|
|
286
|
+
// The other half of that rule. Not deleting unasked must not become leaving debris behind: a
|
|
287
|
+
// scratch site built to check something costs a database volume, a snapshots volume and two
|
|
288
|
+
// per-project images, none of which go when the folder does. So the assistant names what it made
|
|
289
|
+
// and OFFERS to remove it — the deciding stays with the person, the remembering does not.
|
|
290
|
+
'- CLEAN UP AFTER YOUR OWN WORK, BY ASKING. When you built a project or wrote files just to check',
|
|
291
|
+
' something and the goal is met, say plainly what you created and offer to remove it. Do not delete',
|
|
292
|
+
' it yourself, and do not leave it unmentioned. To remove a project use the workspace command —',
|
|
293
|
+
' `bash cmd-tools-remove.sh <project>` from its workspace folder, or `ddev delete -y -O` inside it.',
|
|
294
|
+
' Deleting the folder alone leaves the DDEV registry entry, the database and snapshots volumes and',
|
|
295
|
+
' the per-project built images behind, invisible until the disk is full.',
|
|
296
|
+
'After acting, end your reply with directives, each alone on its own line, so the interface can react:',
|
|
297
|
+
'NAVIGATE:/<workspace> (go to that workspace page, e.g. NAVIGATE:/dev — or its backups page: NAVIGATE:/dev/backups; NAVIGATE:/screens for the screen sessions)',
|
|
298
|
+
'OPEN:<https url> (open a site in a new tab, e.g. after ddev start)',
|
|
299
|
+
'REFRESH (refresh the visible project list)',
|
|
300
|
+
'PROMPT MODE. The mode selector lists only the commands of the workspace the user is on (every',
|
|
301
|
+
'workspace on the home page), so a command they pick is always one that belongs here. When you',
|
|
302
|
+
'suggest something yourself, suggest it from THIS workspace and name what is actually in it — real',
|
|
303
|
+
'project names, real builder scripts, real items from the page context above. Never offer a generic',
|
|
304
|
+
'example ("build a site", "back up a project") or invent a project that is not listed.',
|
|
305
|
+
'When the user picked a command it arrives as',
|
|
306
|
+
'"Run <script> in <workspace>: ..." — they chose it, so carry it out.',
|
|
307
|
+
'When they did NOT pick one (Auto mode — anything that does not start with "Run <script> in"),',
|
|
308
|
+
'you decide what to run, but PROPOSE BEFORE ACTING for anything that builds, installs, removes,',
|
|
309
|
+
'restores, deploys or otherwise changes state:',
|
|
310
|
+
' 1. One line on what you will do and why that command.',
|
|
311
|
+
' 2. The exact command on its own line, in a ```bash block — the real cmd-*.sh with its arguments.',
|
|
312
|
+
' 3. End with the line: PLAN:<workspace>|<one-line summary>',
|
|
313
|
+
'The interface turns that last line into a confirm card offering "Run it", "Keep prompting" and',
|
|
314
|
+
'a link to the target workspace, so do not run the command in that same turn — wait for the',
|
|
315
|
+
'user to confirm. Read-only questions (status, what exists, logs) need no plan: just answer.',
|
|
316
|
+
'Keep replies short and factual; report real command results, never invented ones.',
|
|
317
|
+
`YOUR MEMORY. The team teaches you this workspace, and you keep what they teach in ${memoryDir}/MEMORY.md.`,
|
|
318
|
+
'Read it at the start of a conversation (cat it, if it exists) and use it. When someone teaches you something worth',
|
|
319
|
+
'keeping (a habit of this team, what a project is for, who works on what, an explanation that worked), or you find',
|
|
320
|
+
'it in the workspace yourself, append one dated line under its heading with Bash, for example:',
|
|
321
|
+
`printf '%s\\n' "- 2026-10-25: test/ holds a fresh site per ticket for QA." >> ${memoryDir}/MEMORY.md`,
|
|
322
|
+
'Headings: # Team, # Workspace, # Projects, # Hard parts. One line per fact; update a line rather than repeat it. Never',
|
|
323
|
+
'write a password, key, token, a client\'s confidential detail, or anything someone asked you to forget. Writing to',
|
|
324
|
+
'your memory is the one change you may make without asking.',
|
|
325
|
+
...(avatar ? [
|
|
326
|
+
`YOUR AVATAR IS SHOWN: ${shownAs === 'Druplicon' ? 'Druplicon, a friendly cartoon Drupal drop,' : `${shownAs}, the character the person picked,`} stands in your panel (or floats on the page) and`,
|
|
327
|
+
'acts out what you write. You talk with a development team: developers, testers, designers, site builders, managers.',
|
|
328
|
+
`- Moves: put a tag in square brackets where it fits, at most one per sentence and two or three per reply: ${moves.map((g) => `[${g}]`).join(' ')}.`,
|
|
329
|
+
` Big moves, where it travels the page (at most one per reply, for a real moment, not every reply): ${Object.entries(AVATAR_BIG_MOVES).map(([m, what]) => `[${m}] ${what}`).join(', ')}.`,
|
|
330
|
+
...(Object.keys(moveSet).length ? [` This avatar's own: ${Object.entries(moveSet).map(([m, what]) => `[${m}] ${what}`).join(', ')}.`] : []),
|
|
331
|
+
' [wave]/[bye] hello and goodbye, [nod] yes, [think] a hard question, [idea] the answer, [explain] while you walk',
|
|
332
|
+
' through something, [point] at what you mean, [thumbsup] or [clap] when something works, [cheer] for a release,',
|
|
333
|
+
' [shrug] when nobody can know, [look] while you check, [crossarms] while a long job runs. Never refer to the tags.',
|
|
334
|
+
'Lines of their own the page acts on (use them when they help; the person sees the result, not the line):',
|
|
335
|
+
' BOARD: <short text> writes a line on the board beside the avatar (a step, a command name, a heading); BOARD: clear wipes it;',
|
|
336
|
+
' BOARD: note <text> pins a sticky note on it (a reminder, a decision, a to-do the person can edit).',
|
|
337
|
+
' DRAW: <shape> ... draws on the board, on a 100 x 60 grid, one shape per line, drawn stroke by stroke:',
|
|
338
|
+
' DRAW: box x y w h <label> DRAW: circle x y r <label> DRAW: arrow x1 y1 x2 y2',
|
|
339
|
+
' DRAW: line x1 y1 x2 y2 DRAW: text x y <words> (labels are a few words; no quotes needed)',
|
|
340
|
+
' DRAW: ellipse x y rx ry DRAW: curve x1 y1 x2 y2 x3 y3 ... (a smooth line through the points)',
|
|
341
|
+
' DRAW: poly x1 y1 x2 y2 ... DRAW: polygon x1 y1 ... (closed)',
|
|
342
|
+
' Any shape takes color=, fill=, width=, opacity= and dash: colours ink blue sky teal green amber',
|
|
343
|
+
' coral pink purple grey black white (or #hex). Draw like an artist: thin lines (width=0.2 to 0.4),',
|
|
344
|
+
' a few colours that go together, soft fills (opacity=0.3), curves for organic shapes, layers.',
|
|
345
|
+
' A flow: boxes with labels and arrows between them, each step its own colour.',
|
|
346
|
+
' SHOW: <youtube link> plays that video in a window beside the avatar. Only a real video you are sure exists.',
|
|
347
|
+
' VIDEO: play | pause | forward [seconds] | back [seconds] | seek m:ss | louder | quieter | volume <0-100> | mute |',
|
|
348
|
+
' unmute | speed <0.25-2> | restart | loop | close controls the latest video, as a person would.',
|
|
349
|
+
' BROWSE: <https link> opens that page in a reading window beside the avatar (documentation, an issue, a README).',
|
|
350
|
+
' SEARCH: <words> searches the web in that reading window; then open the best result with BROWSE:.',
|
|
351
|
+
' BROWSE: back | forward | reload | down | up | top | bottom | zoomin | zoomout | read | newtab | close',
|
|
352
|
+
' BROWSE: find <words> | click <the words of a link> drive that window as a person would, one command per line.',
|
|
353
|
+
' When the browser is open, each message tells you its title, address, how far down it is, the text in view and',
|
|
354
|
+
' its links: read from that, scroll on with BROWSE: down, and click on with BROWSE: click <link words>.',
|
|
355
|
+
` MOVE: ${AVATAR_PLACES.join(' | ')} moves the avatar across the page (home is beside your text box, where it lives), or back into the panel.`,
|
|
356
|
+
` SIZE: ${AVATAR_SIZES.join(' | ')} makes the avatar that size: huge to show something off or when asked to be bigger, back to medium after.`,
|
|
357
|
+
] : []),
|
|
358
|
+
...(voice ? [
|
|
359
|
+
'VOICE IS ON: the user hears a spoken summary instead of reading the reply aloud. As the very last',
|
|
360
|
+
'line of every reply, write SUMMARY: followed by one or two short plain sentences saying what you',
|
|
361
|
+
'did or found. No code, paths, URLs, markdown or lists, and no more than about 40 words.',
|
|
362
|
+
] : []),
|
|
363
|
+
].join('\n');
|
|
364
|
+
|
|
365
|
+
// The conversation continues rather than starting over. A thread carries claude's own session id:
|
|
366
|
+
// the first turn names it (--session-id, a uuid we chose), every later turn resumes it (--resume),
|
|
367
|
+
// and the agent keeps what was already said and done. Without this the transcript the page draws
|
|
368
|
+
// is in front of an agent that remembers none of it, which reads as memory and is not.
|
|
369
|
+
//
|
|
370
|
+
// --no-session-persistence is therefore gone: it is the flag that threw the session away.
|
|
371
|
+
const sessionId = (thread && thread.sessionId) || null;
|
|
372
|
+
const wanted = sessionId || crypto.randomUUID();
|
|
373
|
+
// What the avatar's browser shows, when it is open: read from it rather than guessing.
|
|
374
|
+
const p = page && typeof page === 'object' ? page : null;
|
|
375
|
+
if (avatar && p && typeof p.url === 'string') {
|
|
376
|
+
const clean = (v, n) => String(v || '').replace(/[\u0000-\u001f]/g, ' ').slice(0, n);
|
|
377
|
+
message = `${message}\n\n[The browser window, right now. The user did not write this part.]\n`
|
|
378
|
+
+ `Page: ${clean(p.title, 140)} (${clean(p.url, 300)}), ${Number(p.scrolled) || 0}% down.\n`
|
|
379
|
+
+ `In view: ${clean(p.text, 1500)}\n`
|
|
380
|
+
+ `Links you can click: ${(Array.isArray(p.links) ? p.links : []).slice(0, 30).map((l) => `«${clean(l, 60)}»`).join(', ')}`;
|
|
381
|
+
}
|
|
382
|
+
const baseArgs = [
|
|
383
|
+
'-p', message,
|
|
384
|
+
'--output-format', 'json',
|
|
385
|
+
'--append-system-prompt', system,
|
|
386
|
+
'--allowedTools', 'Bash', 'Read', 'Glob', 'Grep', 'Skill',
|
|
387
|
+
'--disallowedTools', 'Write', 'Edit', 'NotebookEdit', 'WebFetch', 'Agent',
|
|
388
|
+
];
|
|
389
|
+
// The agent it runs as (settings.yml assistant.agent, `webship-workspace` by default): that agent's rules
|
|
390
|
+
// and skills, on top of everything above. Only when the agent is installed for this user.
|
|
391
|
+
// An avatar may bring an agent of its own (settings.yml assistant.avatar_agents), while it is shown.
|
|
392
|
+
const agentName = (avatar && cast.agentOf(loadSettings(), String(avatarId || ''))) || require('./speech').assistantSettings(loadSettings()).agent;
|
|
393
|
+
if (agentName && fs.existsSync(path.join(process.env.HOME || '', '.claude', 'agents', `${agentName}.md`))) baseArgs.push('--agent', agentName);
|
|
394
|
+
const withSession = (id, resume) => [...baseArgs, ...(resume ? ['--resume', id] : ['--session-id', id])];
|
|
395
|
+
|
|
396
|
+
let result = await run('claude', withSession(wanted, Boolean(sessionId)), ROOT, { timeoutMs: 15 * 60 * 1000 });
|
|
397
|
+
// A session can be gone — pruned, or written by another machine — and the ask still has to be
|
|
398
|
+
// answered. Start a fresh one rather than reporting the conversation as broken, and say nothing:
|
|
399
|
+
// what the user asked for is the answer, not the bookkeeping.
|
|
400
|
+
if (sessionId && result.code !== 0) {
|
|
401
|
+
result = await run('claude', withSession(crypto.randomUUID(), false), ROOT, { timeoutMs: 15 * 60 * 1000 });
|
|
402
|
+
}
|
|
403
|
+
let newSessionId = null;
|
|
404
|
+
let reply = result.stdout.trim();
|
|
405
|
+
try {
|
|
406
|
+
const parsed = JSON.parse(result.stdout);
|
|
407
|
+
reply = parsed.result || parsed.response || reply;
|
|
408
|
+
// What to resume next time, read back from the run rather than assumed: a resumed session can
|
|
409
|
+
// answer under a different id (a fork), and resuming the one we asked for would lose the turn.
|
|
410
|
+
if (parsed.session_id) newSessionId = String(parsed.session_id);
|
|
411
|
+
} catch (_) { /* raw text fallback */ }
|
|
412
|
+
if (!reply) reply = result.stderr.trim() || 'No response from the assistant.';
|
|
413
|
+
|
|
414
|
+
// Pull the interface directives out of the reply text.
|
|
415
|
+
const directive = {};
|
|
416
|
+
reply = reply.split('\n').filter((line) => {
|
|
417
|
+
// Accepts /<workspace> and /<workspace>/backups
|
|
418
|
+
const nav = line.match(/^\s*NAVIGATE:\/([a-z0-9_-]+)(\/backups)?\s*$/);
|
|
419
|
+
if (nav) { directive.navigate = isValidWorkspace(nav[1]) ? wsUrl(nav[1], nav[2] || '') : HOME_URL(); return false; }
|
|
420
|
+
const open = line.match(/^\s*OPEN:(https?:\/\/\S+)\s*$/);
|
|
421
|
+
if (open) { directive.open = open[1]; return false; }
|
|
422
|
+
// The agent's proposal in Auto mode: keep the workspace and summary,
|
|
423
|
+
// render them as a confirm card rather than leaving a bare marker.
|
|
424
|
+
const plan = line.match(/^\s*PLAN:([a-z0-9_-]+)\|(.+)$/);
|
|
425
|
+
if (plan && isValidWorkspace(plan[1])) { directive.plan = { workspace: plan[1], summary: plan[2].trim() }; return false; }
|
|
426
|
+
if (/^\s*REFRESH\s*$/.test(line)) { directive.refresh = true; return false; }
|
|
427
|
+
// The avatar's lines (only acted on while the avatar is shown, but always taken out of the text).
|
|
428
|
+
const board = line.match(/^\s*BOARD:\s*(.+)$/);
|
|
429
|
+
if (board) { (directive.board = directive.board || []).push(board[1].trim().slice(0, 120)); return false; }
|
|
430
|
+
const draw = line.match(/^\s*DRAW:\s*(.+)$/);
|
|
431
|
+
if (draw) { const d = directive.draw = directive.draw || []; if (d.length < 200) d.push(draw[1].trim().slice(0, 160)); return false; }
|
|
432
|
+
const show = line.match(/^\s*SHOW:\s*(https:\/\/\S+)\s*$/);
|
|
433
|
+
if (show) { const id = youtubeId(show[1]); if (id) directive.show = { video: id }; return false; }
|
|
434
|
+
const browse = line.match(/^\s*BROWSE:\s*(https:\/\/\S+)\s*$/);
|
|
435
|
+
if (browse) { directive.browse = browse[1].slice(0, 2000); return false; }
|
|
436
|
+
const browseCmd = line.match(/^\s*BROWSE:\s*(back|forward|reload|close|newtab|zoomin|zoomout|read|down|up|top|bottom|(?:find|click)\s+.{1,80})\s*$/i);
|
|
437
|
+
if (browseCmd) { const c = directive.browseCommands = directive.browseCommands || []; if (c.length < 10) c.push(browseCmd[1]); return false; }
|
|
438
|
+
const search = line.match(/^\s*SEARCH:\s*(.+)$/);
|
|
439
|
+
if (search) { directive.search = search[1].trim().slice(0, 160); return false; }
|
|
440
|
+
const videoCmd = line.match(/^\s*VIDEO:\s*(play|pause|mute|unmute|louder|quieter|restart|loop|close|(?:forward|back|volume|speed)(?:\s+[\d.]+)?|seek\s+\d{1,2}(?::\d{1,2}){0,2})\s*$/i);
|
|
441
|
+
if (videoCmd) { const c = directive.videoCommands = directive.videoCommands || []; if (c.length < 6) c.push(videoCmd[1]); return false; }
|
|
442
|
+
const move = line.match(/^\s*MOVE:\s*([a-z]+)\s*$/);
|
|
443
|
+
if (move) { if (AVATAR_PLACES.includes(move[1])) directive.move = move[1]; return false; }
|
|
444
|
+
const size = line.match(/^\s*SIZE:\s*([a-z]+)\s*$/);
|
|
445
|
+
if (size) { if (AVATAR_SIZES.includes(size[1])) directive.size = size[1]; return false; }
|
|
446
|
+
const spoken = line.match(/^\s*SUMMARY:\s*(.+)$/);
|
|
447
|
+
if (spoken) { directive.spoken = spoken[1].replace(/[`*_#>|~]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300); return false; }
|
|
448
|
+
return true;
|
|
449
|
+
}).join('\n').trim();
|
|
450
|
+
// [wave], [nod] … : the avatar's moves, in the order written; never left in the text.
|
|
451
|
+
const gestures = [];
|
|
452
|
+
reply = reply.replace(/\s?\[([a-z]+)\]/g, (m, g) => {
|
|
453
|
+
if (!moves.includes(g)) return m;
|
|
454
|
+
if (gestures.length < 6) gestures.push(g);
|
|
455
|
+
return '';
|
|
456
|
+
});
|
|
457
|
+
if (gestures.length) directive.gestures = gestures;
|
|
458
|
+
if (directive.spoken) directive.spoken = directive.spoken.replace(/\[[a-z]+\]/g, '').replace(/\s+/g, ' ').trim();
|
|
459
|
+
let replyHtml = `<p>${esc(reply)}</p>`;
|
|
460
|
+
// Rendered after the reply body when the agent proposed a plan.
|
|
461
|
+
const planHtml = directive.plan ? `
|
|
462
|
+
<div class="plan-card">
|
|
463
|
+
<p class="plan-summary"><span uk-icon="icon: bolt; ratio: .8"></span> ${esc(directive.plan.summary)}</p>
|
|
464
|
+
<div class="plan-actions">
|
|
465
|
+
<button class="uk-button uk-button-primary uk-button-small plan-run" type="button">Run it</button>
|
|
466
|
+
<button class="uk-button uk-button-default uk-button-small plan-keep" type="button">Keep prompting</button>
|
|
467
|
+
<a class="uk-button uk-button-default uk-button-small" href="${esc(wsUrl(directive.plan.workspace))}">Go to ${esc(directive.plan.workspace)}</a>
|
|
468
|
+
</div>
|
|
469
|
+
</div>` : '';
|
|
470
|
+
const mdResult = await new Promise((resolve) => {
|
|
471
|
+
const child = spawn('pandoc', ['-f', 'gfm-raw_html', '-t', 'html'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
472
|
+
let out = '';
|
|
473
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
474
|
+
child.on('close', (code) => resolve(code === 0 ? out : null));
|
|
475
|
+
child.on('error', () => resolve(null));
|
|
476
|
+
child.stdin.write(reply);
|
|
477
|
+
child.stdin.end();
|
|
478
|
+
});
|
|
479
|
+
if (mdResult) replyHtml = mdResult;
|
|
480
|
+
|
|
481
|
+
return { replyHtml: `${replyHtml}${planHtml}`, directive, sessionId: newSessionId };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
module.exports = { assistantReply, pageContextFor, youtubeId, AVATAR_GESTURES, AVATAR_MOVE_SETS, AVATAR_BIG_MOVES };
|