@pimote/pimote 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +35 -24
  2. package/client/build/_app/immutable/assets/0.DzEJOwCY.css +2 -0
  3. package/client/build/_app/immutable/assets/2.BaqEkCa-.css +1 -0
  4. package/client/build/_app/immutable/chunks/Bu9BFOcV.js +5 -0
  5. package/client/build/_app/immutable/chunks/{D_Fpgknp.js → CC5lD39t.js} +1 -1
  6. package/client/build/_app/immutable/chunks/Cofo8G1m.js +1 -0
  7. package/client/build/_app/immutable/chunks/IBhhn0wx.js +1 -0
  8. package/client/build/_app/immutable/chunks/RduuwJ24.js +1 -0
  9. package/client/build/_app/immutable/entry/{app.DO-zgzyy.js → app.D7ddwq0U.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.8XREk-Eq.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.nayop99c.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B2l9JGRO.js → 1.BuW4kxL-.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.BParOJL4.js +54 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +3 -4
  17. package/server/dist/config.js +0 -1
  18. package/server/dist/extension-ui-bridge.js +17 -0
  19. package/server/dist/folder-index.js +1 -1
  20. package/server/dist/index.js +55 -18
  21. package/server/dist/message-mapper.js +1 -0
  22. package/server/dist/paths.js +2 -0
  23. package/server/dist/push-notification.js +11 -0
  24. package/server/dist/server.js +9 -1
  25. package/server/dist/session-manager.js +22 -8
  26. package/server/dist/static-host/gc.js +42 -0
  27. package/server/dist/static-host/http-handler.js +170 -0
  28. package/server/dist/static-host/index.js +117 -0
  29. package/server/dist/static-host/prompt.js +19 -0
  30. package/server/dist/static-host/registry.js +58 -0
  31. package/server/dist/static-host/store.js +44 -0
  32. package/server/dist/static-host/tools.js +118 -0
  33. package/server/dist/voice/fsm/reducers/lifecycle.js +14 -2
  34. package/server/dist/voice/index.js +49 -7
  35. package/server/dist/voice/speechmux-client.js +20 -3
  36. package/server/dist/voice/state-machine.js +7 -0
  37. package/server/dist/voice-orchestrator-boot.js +14 -50
  38. package/server/dist/voice-orchestrator.js +10 -22
  39. package/server/dist/ws-handler.js +4 -2
  40. package/shared/dist/protocol.d.ts +8 -0
  41. package/client/build/_app/immutable/assets/0.C7loWTOC.css +0 -2
  42. package/client/build/_app/immutable/assets/2.D9fiCd8W.css +0 -1
  43. package/client/build/_app/immutable/chunks/BNqgidwO.js +0 -5
  44. package/client/build/_app/immutable/chunks/D26i4pYm.js +0 -1
  45. package/client/build/_app/immutable/chunks/DoVhjU85.js +0 -1
  46. package/client/build/_app/immutable/chunks/DzqbY2XU.js +0 -1
  47. package/client/build/_app/immutable/entry/start.BZlrOH0-.js +0 -1
  48. package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +0 -10
  49. package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +0 -54
  50. package/patches/@mariozechner+pi-coding-agent+0.67.6.patch +0 -24
@@ -4,6 +4,7 @@ import { join, extname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { WebSocketServer } from 'ws';
6
6
  import { WsHandler } from './ws-handler.js';
7
+ import { serveStaticHostRoute } from './static-host/index.js';
7
8
  import crypto from 'node:crypto';
8
9
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
9
10
  const CLIENT_DIR = process.env.CLIENT_DIR || join(__dirname, '..', '..', 'client', 'build');
@@ -79,7 +80,7 @@ async function serveFallback(res) {
79
80
  res.end(JSON.stringify({ error: 'not found' }));
80
81
  }
81
82
  }
82
- export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator) {
83
+ export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator, staticHostRegistry) {
83
84
  const clientVersion = await loadClientVersion();
84
85
  if (clientVersion) {
85
86
  console.log(`[pimote] Client build version: ${clientVersion}`);
@@ -106,6 +107,13 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
106
107
  if (served)
107
108
  return;
108
109
  }
110
+ // 3b. Static-host route — /s/<slug>/* serves agent-registered bundles.
111
+ // Unknown slugs return 404 from the handler and do NOT fall through to the SPA.
112
+ if (req.method === 'GET' || req.method === 'HEAD') {
113
+ const handled = await serveStaticHostRoute(req, res, staticHostRegistry);
114
+ if (handled)
115
+ return;
116
+ }
109
117
  // 4. SPA fallback — serve index.html for unmatched GET routes
110
118
  if (req.method === 'GET') {
111
119
  await serveFallback(res);
@@ -1,4 +1,4 @@
1
- import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@mariozechner/pi-coding-agent';
1
+ import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@earendil-works/pi-coding-agent';
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
4
4
  import { getGitBranch } from './git-branch.js';
@@ -55,7 +55,7 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
55
55
  eventBuffer,
56
56
  status: session.isStreaming ? 'working' : 'idle',
57
57
  needsAttention: false,
58
- lastActivity: Date.now(),
58
+ idleSince: session.isStreaming ? null : Date.now(),
59
59
  unsubscribe: () => { },
60
60
  pendingUiResponses: new Map(),
61
61
  extensionsBound: false,
@@ -68,10 +68,12 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
68
68
  const unsubscribe = session.subscribe((event) => {
69
69
  if (event.type === 'agent_start' && state.status !== 'working') {
70
70
  state.status = 'working';
71
+ state.idleSince = null;
71
72
  callbacks.onStatusChange?.(sessionId, folderPath);
72
73
  }
73
74
  else if (event.type === 'agent_end' && state.status !== 'idle') {
74
75
  state.status = 'idle';
76
+ state.idleSince = Date.now();
75
77
  state.needsAttention = true;
76
78
  if (slotRef.slot)
77
79
  callbacks.onAgentEnd?.(sessionId, slotRef.slot);
@@ -145,19 +147,25 @@ export class PimoteSessionManager {
145
147
  * reap, explicit close). Consumers use this to drop external bookkeeping
146
148
  * (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
147
149
  onBeforeSessionClose;
148
- constructor(config, pushNotificationService) {
150
+ staticHostFactory;
151
+ constructor(config, pushNotificationService, options = {}) {
149
152
  this.config = config;
150
153
  this.pushNotificationService = pushNotificationService;
151
154
  this.authStorage = AuthStorage.create();
152
155
  this.modelRegistry = ModelRegistry.create(this.authStorage);
156
+ this.staticHostFactory = options.staticHostFactory;
153
157
  }
154
158
  /**
155
159
  * Build the voice extension factory for this server's config, if possible.
156
- * Returns undefined (and logs a warning) when neither voice-specific model
157
- * refs nor fallback defaultProvider/defaultModel are configured — existing
158
- * non-voice deployments continue to work unchanged.
160
+ * Returns undefined when voice is not configured (no speechmux URLs) or
161
+ * when neither voice-specific model refs nor fallback defaultProvider/
162
+ * defaultModel are configured. Non-voice deployments continue to work
163
+ * unchanged — sessions simply don't load `@pimote/voice` at all.
159
164
  */
160
165
  buildVoiceExtensionFactory() {
166
+ if (!this.config.voice?.speechmuxSignalUrl || !this.config.voice?.speechmuxLlmWsUrl) {
167
+ return undefined;
168
+ }
161
169
  const interpreter = this.config.defaultInterpreterModel ??
162
170
  (this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
163
171
  const worker = this.config.defaultWorkerModel ??
@@ -178,6 +186,8 @@ export class PimoteSessionManager {
178
186
  const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
179
187
  const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
180
188
  const voiceExtensionFactory = this.buildVoiceExtensionFactory();
189
+ const staticHostFactory = this.staticHostFactory;
190
+ const extensionFactories = [...(voiceExtensionFactory ? [voiceExtensionFactory] : []), ...(staticHostFactory ? [staticHostFactory] : [])];
181
191
  const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
182
192
  const eventBus = createEventBus();
183
193
  eventBusRef.current = eventBus;
@@ -188,7 +198,7 @@ export class PimoteSessionManager {
188
198
  modelRegistry: sharedModelRegistry,
189
199
  resourceLoaderOptions: {
190
200
  eventBus,
191
- ...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
201
+ ...(extensionFactories.length ? { extensionFactories } : {}),
192
202
  },
193
203
  });
194
204
  return {
@@ -368,7 +378,11 @@ export class PimoteSessionManager {
368
378
  }
369
379
  const clientId = slot.connection?.connectedClientId ?? null;
370
380
  const hasConnectedClient = clientId !== null && (isClientConnected?.(clientId) ?? false);
371
- if (!hasConnectedClient && Date.now() - slot.sessionState.lastActivity > idleTimeout) {
381
+ // Only idle (non-streaming) sessions are eligible for reaping. `idleSince` is set on
382
+ // `agent_end` and cleared on `agent_start`, so a working session can never be reaped
383
+ // here, regardless of how long it's been since a client was connected.
384
+ const idleSince = slot.sessionState.idleSince;
385
+ if (!hasConnectedClient && idleSince !== null && Date.now() - idleSince > idleTimeout) {
372
386
  this.closeSession(sessionId).catch(() => {
373
387
  // Best-effort cleanup — swallow errors during idle reaping
374
388
  });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Boot-time garbage collection of the per-session static-host persistence dir.
3
+ *
4
+ * Reads `storeDir`, deletes any `<sessionId>.json` whose sessionId is not in
5
+ * `validSessionIds`. The directory not existing is not an error (returns OK
6
+ * without doing anything). Has no knowledge of the registry, the extension,
7
+ * or the HTTP layer.
8
+ *
9
+ * Called from `server/src/index.ts` after `FolderIndex` initialisation and
10
+ * before `server.start()` so the HTTP route never sees a stale slug pointing
11
+ * at a folder that the agent has long since deleted.
12
+ */
13
+ import { readdir, unlink } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ export async function gcStaticHostStore(args) {
16
+ const { storeDir, validSessionIds } = args;
17
+ let entries;
18
+ try {
19
+ entries = await readdir(storeDir);
20
+ }
21
+ catch (err) {
22
+ if (err.code === 'ENOENT')
23
+ return;
24
+ throw err;
25
+ }
26
+ const suffix = '.json';
27
+ for (const name of entries) {
28
+ if (!name.endsWith(suffix))
29
+ continue;
30
+ const sessionId = name.slice(0, -suffix.length);
31
+ if (validSessionIds.has(sessionId))
32
+ continue;
33
+ try {
34
+ await unlink(join(storeDir, name));
35
+ }
36
+ catch (err) {
37
+ if (err.code === 'ENOENT')
38
+ continue;
39
+ throw err;
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,170 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ const MIME_TYPES = {
5
+ '.html': 'text/html; charset=utf-8',
6
+ '.htm': 'text/html; charset=utf-8',
7
+ '.js': 'application/javascript; charset=utf-8',
8
+ '.mjs': 'application/javascript; charset=utf-8',
9
+ '.css': 'text/css; charset=utf-8',
10
+ '.json': 'application/json; charset=utf-8',
11
+ '.txt': 'text/plain; charset=utf-8',
12
+ '.md': 'text/markdown; charset=utf-8',
13
+ '.svg': 'image/svg+xml',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ '.ico': 'image/x-icon',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2',
22
+ '.ttf': 'font/ttf',
23
+ '.map': 'application/json; charset=utf-8',
24
+ };
25
+ // Match `/s/<slug>/<remainder>` — the trailing slash after the slug is
26
+ // REQUIRED. Without it, browsers resolve relative asset URLs against `/s/`
27
+ // instead of `/s/<slug>/` and every asset 404s. `/s/<slug>` (no slash) is
28
+ // handled separately below with a 301 redirect to `/s/<slug>/`.
29
+ const PREFIX_RE = /^\/s\/([a-z0-9-]+)\/(.*)$/;
30
+ const PREFIX_NO_SLASH_RE = /^\/s\/([a-z0-9-]+)$/;
31
+ function send404(res) {
32
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });
33
+ res.end('Not Found');
34
+ }
35
+ /**
36
+ * HTTP handler for the `/s/<slug>/*` route prefix.
37
+ *
38
+ * Behaviour:
39
+ * - Path does not match `/s/<slug>/...` => returns `false` (caller falls
40
+ * through to the SPA fallback).
41
+ * - Slug not in the registry => 404, returns `true` (does NOT fall through).
42
+ * - Resolved path escapes the registered folder (e.g. `..` traversal) => 404.
43
+ * - Resolved path is a directory => append `index.html`.
44
+ * - File missing => 404. File present => 200, content-type by extension,
45
+ * streamed body, no-cache response headers.
46
+ *
47
+ * Returns `true` if the request was handled (any 2xx/4xx response written),
48
+ * `false` only on prefix mismatch.
49
+ */
50
+ export async function serveStaticHostRoute(req, res, registry) {
51
+ if (req.method !== 'GET' && req.method !== 'HEAD')
52
+ return false;
53
+ let pathname;
54
+ try {
55
+ pathname = new URL(req.url ?? '', 'http://x').pathname;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ // `/s/<slug>` (no trailing slash) — redirect to the canonical form so the
61
+ // browser resolves relative asset URLs against `/s/<slug>/`.
62
+ const noSlashMatch = PREFIX_NO_SLASH_RE.exec(pathname);
63
+ if (noSlashMatch) {
64
+ const [, slug] = noSlashMatch;
65
+ if (!registry.lookup(slug)) {
66
+ send404(res);
67
+ return true;
68
+ }
69
+ res.writeHead(301, { Location: `/s/${slug}/`, 'Cache-Control': 'no-cache, no-store, must-revalidate' });
70
+ res.end();
71
+ return true;
72
+ }
73
+ const m = PREFIX_RE.exec(pathname);
74
+ if (!m)
75
+ return false;
76
+ const [, slug, rawRemainder = ''] = m;
77
+ const reg = registry.lookup(slug);
78
+ if (!reg) {
79
+ send404(res);
80
+ return true;
81
+ }
82
+ let decoded;
83
+ try {
84
+ decoded = decodeURIComponent(rawRemainder);
85
+ }
86
+ catch {
87
+ send404(res);
88
+ return true;
89
+ }
90
+ // Defence-in-depth: reject any decoded path containing backslash, NUL,
91
+ // or `..` segments before resolving.
92
+ if (decoded.includes('\\') || decoded.includes('\u0000')) {
93
+ send404(res);
94
+ return true;
95
+ }
96
+ const segments = decoded.split('/');
97
+ if (segments.some((s) => s === '..')) {
98
+ send404(res);
99
+ return true;
100
+ }
101
+ const folderPath = reg.folderPath;
102
+ let resolved = path.resolve(folderPath, decoded);
103
+ if (resolved !== folderPath && !resolved.startsWith(folderPath + path.sep)) {
104
+ send404(res);
105
+ return true;
106
+ }
107
+ let st;
108
+ try {
109
+ st = await stat(resolved);
110
+ }
111
+ catch {
112
+ send404(res);
113
+ return true;
114
+ }
115
+ if (st.isDirectory()) {
116
+ resolved = path.join(resolved, 'index.html');
117
+ try {
118
+ st = await stat(resolved);
119
+ }
120
+ catch {
121
+ send404(res);
122
+ return true;
123
+ }
124
+ }
125
+ if (!st.isFile()) {
126
+ send404(res);
127
+ return true;
128
+ }
129
+ const ext = path.extname(resolved).toLowerCase();
130
+ const mime = MIME_TYPES[ext] || 'application/octet-stream';
131
+ res.writeHead(200, {
132
+ 'Content-Type': mime,
133
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
134
+ });
135
+ if (req.method === 'HEAD') {
136
+ res.end();
137
+ return true;
138
+ }
139
+ // Stream errors AFTER `writeHead(200)` cannot be turned into a 5xx — headers
140
+ // are already on the wire. Destroy the response and resolve cleanly so the
141
+ // rejection does not bubble out of the async request handler as an
142
+ // unhandled rejection.
143
+ await new Promise((resolvePromise) => {
144
+ const stream = createReadStream(resolved);
145
+ let settled = false;
146
+ const settle = () => {
147
+ if (settled)
148
+ return;
149
+ settled = true;
150
+ resolvePromise();
151
+ };
152
+ stream.on('error', (err) => {
153
+ console.warn('[static-host] stream error while serving', resolved, err);
154
+ try {
155
+ res.destroy();
156
+ }
157
+ catch {
158
+ // ignore
159
+ }
160
+ settle();
161
+ });
162
+ stream.on('end', settle);
163
+ res.on('close', () => {
164
+ stream.destroy();
165
+ settle();
166
+ });
167
+ stream.pipe(res);
168
+ });
169
+ return true;
170
+ }
@@ -0,0 +1,117 @@
1
+ import { Type } from 'typebox';
2
+ import { executeRegisterTool, executeRemoveTool } from './tools.js';
3
+ import { STATIC_HOST_TOOL_DESCRIPTION } from './prompt.js';
4
+ export { InMemoryStaticHostRegistry } from './registry.js';
5
+ export { FileStaticHostStore } from './store.js';
6
+ export { serveStaticHostRoute } from './http-handler.js';
7
+ export { gcStaticHostStore } from './gc.js';
8
+ /**
9
+ * Build the pi `ExtensionFactory` for the static-host extension.
10
+ *
11
+ * The returned factory is threaded into every pi session via
12
+ * `resourceLoaderOptions.extensionFactories`. It captures `registry` and
13
+ * `store` by closure and resolves the per-session `sessionId` lazily through
14
+ * the `ctx.sessionManager.getSessionId()` available on event handlers (the pi
15
+ * `ExtensionFactory` itself receives only `ExtensionAPI`, not the sessionId).
16
+ *
17
+ * Lifecycle for one session S:
18
+ * - First handler invocation (`session_start`): reads
19
+ * `${storeDir}/${S}.json` if present, replays each entry into the registry,
20
+ * emits a panel snapshot.
21
+ * - Tools (`pimote_static_host`, `pimote_static_host_remove`): update the
22
+ * in-memory list, atomically rewrite the file, update the registry,
23
+ * re-emit the panel snapshot.
24
+ * - `session_shutdown` => `registry.unregisterAllForSession(S)`. The file
25
+ * stays on disk for the next session load.
26
+ */
27
+ export function createStaticHostExtension(opts) {
28
+ const { registry, store } = opts;
29
+ function buildCardsFor(sessionId) {
30
+ return registry.listForSession(sessionId).map((entry) => {
31
+ const card = {
32
+ id: `static-host:${entry.slug}`,
33
+ header: {
34
+ title: entry.cardMetadata.title,
35
+ ...(entry.cardMetadata.tag !== undefined ? { tag: entry.cardMetadata.tag } : {}),
36
+ },
37
+ href: `/s/${entry.slug}/`,
38
+ ...(entry.cardMetadata.color !== undefined ? { color: entry.cardMetadata.color } : {}),
39
+ };
40
+ return card;
41
+ });
42
+ }
43
+ function emitPanelCards(pi, sessionId) {
44
+ const cards = buildCardsFor(sessionId);
45
+ pi.events.emit('pimote:panels', { type: 'cards', namespace: 'static-host', cards });
46
+ }
47
+ function toolDeps(pi, sessionId) {
48
+ return {
49
+ registry,
50
+ store,
51
+ sessionId,
52
+ emitPanelCards: () => emitPanelCards(pi, sessionId),
53
+ };
54
+ }
55
+ return (pi) => {
56
+ pi.registerTool({
57
+ name: 'pimote_static_host',
58
+ label: 'Host static bundle',
59
+ description: STATIC_HOST_TOOL_DESCRIPTION,
60
+ parameters: Type.Object({
61
+ slug: Type.String({ description: 'Short URL slug, lowercase [a-z0-9-]+ with no leading/trailing dash.' }),
62
+ folder: Type.String({ description: 'Absolute path to the folder containing the bundle (must contain index.html).' }),
63
+ title: Type.String({ description: 'Title displayed on the panel card.' }),
64
+ tag: Type.Optional(Type.String({ description: 'Optional short tag shown next to the title.' })),
65
+ color: Type.Optional(Type.Union([Type.Literal('accent'), Type.Literal('success'), Type.Literal('warning'), Type.Literal('error'), Type.Literal('muted')], {
66
+ description: 'Optional card color.',
67
+ })),
68
+ }),
69
+ execute: async (_callId, input, _abort, _meta, ctx) => {
70
+ const sessionId = ctx.sessionManager.getSessionId();
71
+ const out = await executeRegisterTool(input, toolDeps(pi, sessionId));
72
+ return { content: [{ type: 'text', text: JSON.stringify(out) }], details: out };
73
+ },
74
+ });
75
+ pi.registerTool({
76
+ name: 'pimote_static_host_remove',
77
+ label: 'Remove hosted bundle',
78
+ description: 'Unregister a previously hosted static bundle by slug.',
79
+ parameters: Type.Object({
80
+ slug: Type.String({ description: 'Slug of the bundle to remove.' }),
81
+ }),
82
+ execute: async (_callId, input, _abort, _meta, ctx) => {
83
+ const sessionId = ctx.sessionManager.getSessionId();
84
+ const out = await executeRemoveTool(input, toolDeps(pi, sessionId));
85
+ return { content: [{ type: 'text', text: JSON.stringify(out) }], details: out };
86
+ },
87
+ });
88
+ pi.on('session_start', async (_ev, ctx) => {
89
+ const sessionId = ctx.sessionManager.getSessionId();
90
+ const file = await store.read(sessionId);
91
+ if (!file)
92
+ return;
93
+ for (const entry of file.entries) {
94
+ try {
95
+ registry.register({
96
+ slug: entry.slug,
97
+ folderPath: entry.folderPath,
98
+ sessionId,
99
+ cardMetadata: entry.cardMetadata,
100
+ });
101
+ }
102
+ catch (err) {
103
+ // Defensive: a slug conflict (e.g. two sessions persisted the same
104
+ // slug, or another session reloaded earlier this boot) must not
105
+ // abort the whole replay loop and leave the session partially
106
+ // loaded. Skip the conflicting entry and continue.
107
+ console.warn(`[static-host] session_start: skipping persisted entry ${entry.slug} for session ${sessionId}`, err);
108
+ }
109
+ }
110
+ emitPanelCards(pi, sessionId);
111
+ });
112
+ pi.on('session_shutdown', async (_ev, ctx) => {
113
+ const sessionId = ctx.sessionManager.getSessionId();
114
+ registry.unregisterAllForSession(sessionId);
115
+ });
116
+ };
117
+ }
@@ -0,0 +1,19 @@
1
+ // Description text attached to the `pimote_static_host` tool. The string
2
+ // is visible to the model in the system prompt's tool listing and must
3
+ // substantively cover the two primary use cases, the mandatory responsive-
4
+ // layout rule (with explicit mobile + desktop breakpoints), the no-secrets
5
+ // rule, and a brief workflow note. See `docs/plans/static-resources.md`.
6
+ export const STATIC_HOST_TOOL_DESCRIPTION = [
7
+ "Host a static HTML/asset bundle from a local folder so the user can view it in their browser. Returns a URL and creates a tappable card in the user's session pointing at the bundle.",
8
+ '',
9
+ '**When to use this:**',
10
+ '',
11
+ '1. **On-the-fly reports and visualisations.** Reach for this whenever you need to present information that would benefit from richer formatting than plain markdown — charts, comparison tables across many dimensions, syntax-highlighted code walkthroughs, before/after diffs, navigable trees of nested structures. A well-designed HTML report is much easier to read and navigate than a wall of markdown in the chat.',
12
+ '2. **Ad-hoc interactive tools.** Small single-purpose tools the user can play with for a little while — calculators, form-driven explorers, lightweight UIs that call external APIs via embedded JavaScript, in-memory data playgrounds, anything where interactivity adds value beyond static text. The bundle is real same-origin JS; it can fetch, do DOM things, persist to localStorage, whatever. Use this when the user needs a temporary tool tailored to the task at hand rather than a hand-rolled answer.',
13
+ '',
14
+ '**Mandatory: responsive, mobile-and-desktop layout.** Always design the bundle to work equally well on both desktop and mobile form factors. The user may view the same report on either device — even the same report on both. Use fluid layouts, relative units, and media queries. Mentally test your layout at ~360px wide and ~1440px wide before considering it done.',
15
+ '',
16
+ '**No secrets in the bundle.** Bundle files are served verbatim to the browser. Do not embed API keys, tokens, or any other credentials, and do not build bundles that depend on calling services that require them — there is no secret-management story for static-hosted bundles. Stick to public endpoints, in-memory state, and APIs that work without auth.',
17
+ '',
18
+ "**Workflow.** Generate the bundle (with at minimum an `index.html`) in a folder, then call this tool with the folder's absolute path, a short descriptive slug, and a title for the card. The user sees a card in their session; tapping it navigates them to the bundle, and browser-back returns them to the main pimote UI.",
19
+ ].join('\n');
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Default in-memory implementation backed by a `Map<slug, StaticHostRegistration>`
3
+ * with a secondary `Map<sessionId, Set<slug>>` for fast `unregisterAllForSession`.
4
+ */
5
+ export class InMemoryStaticHostRegistry {
6
+ bySlug = new Map();
7
+ bySession = new Map();
8
+ register(reg) {
9
+ if (this.bySlug.has(reg.slug)) {
10
+ throw new Error(`static-host slug already registered: ${reg.slug}`);
11
+ }
12
+ this.bySlug.set(reg.slug, reg);
13
+ let set = this.bySession.get(reg.sessionId);
14
+ if (!set) {
15
+ set = new Set();
16
+ this.bySession.set(reg.sessionId, set);
17
+ }
18
+ set.add(reg.slug);
19
+ }
20
+ unregister(slug) {
21
+ const entry = this.bySlug.get(slug);
22
+ if (!entry)
23
+ return;
24
+ this.bySlug.delete(slug);
25
+ const set = this.bySession.get(entry.sessionId);
26
+ if (set) {
27
+ set.delete(slug);
28
+ if (set.size === 0)
29
+ this.bySession.delete(entry.sessionId);
30
+ }
31
+ }
32
+ unregisterAllForSession(sessionId) {
33
+ const set = this.bySession.get(sessionId);
34
+ if (!set)
35
+ return;
36
+ for (const slug of set)
37
+ this.bySlug.delete(slug);
38
+ this.bySession.delete(sessionId);
39
+ }
40
+ lookup(slug) {
41
+ return this.bySlug.get(slug);
42
+ }
43
+ has(slug) {
44
+ return this.bySlug.has(slug);
45
+ }
46
+ listForSession(sessionId) {
47
+ const set = this.bySession.get(sessionId);
48
+ if (!set)
49
+ return [];
50
+ const out = [];
51
+ for (const slug of set) {
52
+ const entry = this.bySlug.get(slug);
53
+ if (entry)
54
+ out.push(entry);
55
+ }
56
+ return out;
57
+ }
58
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Filesystem-backed `StaticHostStore`. One file per sessionId under `storeDir`.
5
+ */
6
+ export class FileStaticHostStore {
7
+ storeDir;
8
+ constructor(storeDir) {
9
+ this.storeDir = storeDir;
10
+ }
11
+ pathFor(sessionId) {
12
+ return join(this.storeDir, `${sessionId}.json`);
13
+ }
14
+ async read(sessionId) {
15
+ const path = this.pathFor(sessionId);
16
+ let raw;
17
+ try {
18
+ raw = await readFile(path, 'utf-8');
19
+ }
20
+ catch (err) {
21
+ if (err.code === 'ENOENT')
22
+ return undefined;
23
+ throw err;
24
+ }
25
+ return JSON.parse(raw);
26
+ }
27
+ async write(sessionId, file) {
28
+ await mkdir(this.storeDir, { recursive: true });
29
+ const finalPath = this.pathFor(sessionId);
30
+ const tmpPath = finalPath + '.tmp';
31
+ await writeFile(tmpPath, JSON.stringify(file, null, 2) + '\n', 'utf-8');
32
+ await rename(tmpPath, finalPath);
33
+ }
34
+ async remove(sessionId) {
35
+ try {
36
+ await unlink(this.pathFor(sessionId));
37
+ }
38
+ catch (err) {
39
+ if (err.code === 'ENOENT')
40
+ return;
41
+ throw err;
42
+ }
43
+ }
44
+ }