@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.
- package/README.md +35 -24
- package/client/build/_app/immutable/assets/0.DzEJOwCY.css +2 -0
- package/client/build/_app/immutable/assets/2.BaqEkCa-.css +1 -0
- package/client/build/_app/immutable/chunks/Bu9BFOcV.js +5 -0
- package/client/build/_app/immutable/chunks/{D_Fpgknp.js → CC5lD39t.js} +1 -1
- package/client/build/_app/immutable/chunks/Cofo8G1m.js +1 -0
- package/client/build/_app/immutable/chunks/IBhhn0wx.js +1 -0
- package/client/build/_app/immutable/chunks/RduuwJ24.js +1 -0
- package/client/build/_app/immutable/entry/{app.DO-zgzyy.js → app.D7ddwq0U.js} +2 -2
- package/client/build/_app/immutable/entry/start.8XREk-Eq.js +1 -0
- package/client/build/_app/immutable/nodes/0.nayop99c.js +10 -0
- package/client/build/_app/immutable/nodes/{1.B2l9JGRO.js → 1.BuW4kxL-.js} +1 -1
- package/client/build/_app/immutable/nodes/2.BParOJL4.js +54 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +7 -7
- package/package.json +3 -4
- package/server/dist/config.js +0 -1
- package/server/dist/extension-ui-bridge.js +17 -0
- package/server/dist/folder-index.js +1 -1
- package/server/dist/index.js +55 -18
- package/server/dist/message-mapper.js +1 -0
- package/server/dist/paths.js +2 -0
- package/server/dist/push-notification.js +11 -0
- package/server/dist/server.js +9 -1
- package/server/dist/session-manager.js +22 -8
- package/server/dist/static-host/gc.js +42 -0
- package/server/dist/static-host/http-handler.js +170 -0
- package/server/dist/static-host/index.js +117 -0
- package/server/dist/static-host/prompt.js +19 -0
- package/server/dist/static-host/registry.js +58 -0
- package/server/dist/static-host/store.js +44 -0
- package/server/dist/static-host/tools.js +118 -0
- package/server/dist/voice/fsm/reducers/lifecycle.js +14 -2
- package/server/dist/voice/index.js +49 -7
- package/server/dist/voice/speechmux-client.js +20 -3
- package/server/dist/voice/state-machine.js +7 -0
- package/server/dist/voice-orchestrator-boot.js +14 -50
- package/server/dist/voice-orchestrator.js +10 -22
- package/server/dist/ws-handler.js +4 -2
- package/shared/dist/protocol.d.ts +8 -0
- package/client/build/_app/immutable/assets/0.C7loWTOC.css +0 -2
- package/client/build/_app/immutable/assets/2.D9fiCd8W.css +0 -1
- package/client/build/_app/immutable/chunks/BNqgidwO.js +0 -5
- package/client/build/_app/immutable/chunks/D26i4pYm.js +0 -1
- package/client/build/_app/immutable/chunks/DoVhjU85.js +0 -1
- package/client/build/_app/immutable/chunks/DzqbY2XU.js +0 -1
- package/client/build/_app/immutable/entry/start.BZlrOH0-.js +0 -1
- package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +0 -10
- package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +0 -54
- package/patches/@mariozechner+pi-coding-agent+0.67.6.patch +0 -24
package/server/dist/server.js
CHANGED
|
@@ -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 '@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
* refs nor fallback defaultProvider/
|
|
158
|
-
*
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
+
}
|