@pimote/pimote 0.3.1 → 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/{CklMSqcv.js → CC5lD39t.js} +1 -1
- package/client/build/_app/immutable/chunks/Cofo8G1m.js +1 -0
- package/client/build/_app/immutable/chunks/{CO_BwWGt.js → IBhhn0wx.js} +1 -1
- package/client/build/_app/immutable/chunks/RduuwJ24.js +1 -0
- package/client/build/_app/immutable/entry/{app.B-HFVtpC.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.CmxFYjRm.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 -23
- package/server/dist/message-mapper.js +1 -0
- package/server/dist/paths.js +2 -0
- package/server/dist/server.js +9 -1
- package/server/dist/session-manager.js +14 -6
- 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/index.js +31 -6
- package/server/dist/voice/speechmux-client.js +20 -3
- package/server/dist/voice-orchestrator-boot.js +14 -50
- package/server/dist/voice-orchestrator.js +10 -22
- 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.DwPXxSa-.css +0 -1
- package/client/build/_app/immutable/chunks/-Lc-U-GJ.js +0 -1
- package/client/build/_app/immutable/chunks/D1INvMB9.js +0 -1
- package/client/build/_app/immutable/chunks/D1vhgXpq.js +0 -5
- package/client/build/_app/immutable/entry/start.DJTQ8-sD.js +0 -1
- package/client/build/_app/immutable/nodes/0.CepAO4xf.js +0 -10
- package/client/build/_app/immutable/nodes/2.DAtqfmki.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';
|
|
@@ -147,19 +147,25 @@ export class PimoteSessionManager {
|
|
|
147
147
|
* reap, explicit close). Consumers use this to drop external bookkeeping
|
|
148
148
|
* (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
|
|
149
149
|
onBeforeSessionClose;
|
|
150
|
-
|
|
150
|
+
staticHostFactory;
|
|
151
|
+
constructor(config, pushNotificationService, options = {}) {
|
|
151
152
|
this.config = config;
|
|
152
153
|
this.pushNotificationService = pushNotificationService;
|
|
153
154
|
this.authStorage = AuthStorage.create();
|
|
154
155
|
this.modelRegistry = ModelRegistry.create(this.authStorage);
|
|
156
|
+
this.staticHostFactory = options.staticHostFactory;
|
|
155
157
|
}
|
|
156
158
|
/**
|
|
157
159
|
* Build the voice extension factory for this server's config, if possible.
|
|
158
|
-
* Returns undefined
|
|
159
|
-
* refs nor fallback defaultProvider/
|
|
160
|
-
*
|
|
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.
|
|
161
164
|
*/
|
|
162
165
|
buildVoiceExtensionFactory() {
|
|
166
|
+
if (!this.config.voice?.speechmuxSignalUrl || !this.config.voice?.speechmuxLlmWsUrl) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
163
169
|
const interpreter = this.config.defaultInterpreterModel ??
|
|
164
170
|
(this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
|
|
165
171
|
const worker = this.config.defaultWorkerModel ??
|
|
@@ -180,6 +186,8 @@ export class PimoteSessionManager {
|
|
|
180
186
|
const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
|
|
181
187
|
const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
|
|
182
188
|
const voiceExtensionFactory = this.buildVoiceExtensionFactory();
|
|
189
|
+
const staticHostFactory = this.staticHostFactory;
|
|
190
|
+
const extensionFactories = [...(voiceExtensionFactory ? [voiceExtensionFactory] : []), ...(staticHostFactory ? [staticHostFactory] : [])];
|
|
183
191
|
const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
184
192
|
const eventBus = createEventBus();
|
|
185
193
|
eventBusRef.current = eventBus;
|
|
@@ -190,7 +198,7 @@ export class PimoteSessionManager {
|
|
|
190
198
|
modelRegistry: sharedModelRegistry,
|
|
191
199
|
resourceLoaderOptions: {
|
|
192
200
|
eventBus,
|
|
193
|
-
...(
|
|
201
|
+
...(extensionFactories.length ? { extensionFactories } : {}),
|
|
194
202
|
},
|
|
195
203
|
});
|
|
196
204
|
return {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Validates and normalises a slug.
|
|
5
|
+
*
|
|
6
|
+
* Rules: lowercase alphanumerics and hyphens, no leading/trailing dash,
|
|
7
|
+
* non-empty, and a reasonable length cap (<= 64).
|
|
8
|
+
*
|
|
9
|
+
* Returns `null` if invalid.
|
|
10
|
+
*/
|
|
11
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
+
export function validateSlug(slug) {
|
|
13
|
+
if (typeof slug !== 'string')
|
|
14
|
+
return null;
|
|
15
|
+
if (slug.length === 0 || slug.length > 64)
|
|
16
|
+
return null;
|
|
17
|
+
if (!SLUG_RE.test(slug))
|
|
18
|
+
return null;
|
|
19
|
+
return slug;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a slug against the registry. Returns the input slug if it is free;
|
|
23
|
+
* otherwise appends `-2`, `-3`, ... until a free slug is found.
|
|
24
|
+
*
|
|
25
|
+
* The caller must have already validated the input slug via `validateSlug`.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveSlugCollision(slug, registry) {
|
|
28
|
+
if (!registry.has(slug))
|
|
29
|
+
return slug;
|
|
30
|
+
for (let i = 2;; i++) {
|
|
31
|
+
const candidate = `${slug}-${i}`;
|
|
32
|
+
if (!registry.has(candidate))
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Execute the `pimote_static_host` tool body.
|
|
38
|
+
*
|
|
39
|
+
* Throws on validation failure (invalid slug, missing folder, no index.html).
|
|
40
|
+
* On success: updates the in-memory list for the session, atomically rewrites
|
|
41
|
+
* the persistence file, calls `registry.register(...)`, and emits the panel
|
|
42
|
+
* snapshot.
|
|
43
|
+
*
|
|
44
|
+
* Concurrency assumption: pi serializes tool calls within a single session,
|
|
45
|
+
* so the read-modify-write sequence on `store` here is safe. If that ever
|
|
46
|
+
* changes (parallel tool execution per session), this body must be revisited
|
|
47
|
+
* — the existing read/write pair would race and the registry/disk could
|
|
48
|
+
* desync. Re-derive entries from `registry.listForSession(sessionId)` after
|
|
49
|
+
* `registry.register`, or add a per-session lock.
|
|
50
|
+
*/
|
|
51
|
+
export async function executeRegisterTool(input, deps) {
|
|
52
|
+
const validSlug = validateSlug(input.slug);
|
|
53
|
+
if (validSlug === null) {
|
|
54
|
+
throw new Error(`invalid slug: ${JSON.stringify(input.slug)}`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof input.folder !== 'string' || !isAbsolute(input.folder)) {
|
|
57
|
+
throw new Error(`folder must be an absolute path: ${JSON.stringify(input.folder)}`);
|
|
58
|
+
}
|
|
59
|
+
let folderStat;
|
|
60
|
+
try {
|
|
61
|
+
folderStat = await stat(input.folder);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`folder does not exist: ${input.folder}`);
|
|
65
|
+
}
|
|
66
|
+
if (!folderStat.isDirectory()) {
|
|
67
|
+
throw new Error(`folder is not a directory: ${input.folder}`);
|
|
68
|
+
}
|
|
69
|
+
const indexPath = join(input.folder, 'index.html');
|
|
70
|
+
let indexStat;
|
|
71
|
+
try {
|
|
72
|
+
indexStat = await stat(indexPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error(`folder has no index.html: ${input.folder}`);
|
|
76
|
+
}
|
|
77
|
+
if (!indexStat.isFile()) {
|
|
78
|
+
throw new Error(`index.html is not a file: ${indexPath}`);
|
|
79
|
+
}
|
|
80
|
+
const resolved = resolveSlugCollision(validSlug, deps.registry);
|
|
81
|
+
const cardMetadata = {
|
|
82
|
+
title: input.title,
|
|
83
|
+
...(input.tag !== undefined ? { tag: input.tag } : {}),
|
|
84
|
+
...(input.color !== undefined ? { color: input.color } : {}),
|
|
85
|
+
};
|
|
86
|
+
const existing = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
|
|
87
|
+
const entries = [...existing.entries, { slug: resolved, folderPath: input.folder, cardMetadata }];
|
|
88
|
+
const file = { version: 1, entries };
|
|
89
|
+
await deps.store.write(deps.sessionId, file);
|
|
90
|
+
deps.registry.register({
|
|
91
|
+
slug: resolved,
|
|
92
|
+
folderPath: input.folder,
|
|
93
|
+
sessionId: deps.sessionId,
|
|
94
|
+
cardMetadata,
|
|
95
|
+
});
|
|
96
|
+
deps.emitPanelCards();
|
|
97
|
+
return { slug: resolved, url: `/s/${resolved}/` };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Execute the `pimote_static_host_remove` tool body. Returns `{ removed: false }`
|
|
101
|
+
* when the slug is not owned by this session.
|
|
102
|
+
*
|
|
103
|
+
* Concurrency assumption: same as `executeRegisterTool` — relies on pi's
|
|
104
|
+
* per-session serialization of tool calls. If that changes, this body must
|
|
105
|
+
* be revisited.
|
|
106
|
+
*/
|
|
107
|
+
export async function executeRemoveTool(input, deps) {
|
|
108
|
+
const existing = deps.registry.lookup(input.slug);
|
|
109
|
+
if (!existing || existing.sessionId !== deps.sessionId) {
|
|
110
|
+
return { removed: false };
|
|
111
|
+
}
|
|
112
|
+
const file = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
|
|
113
|
+
const entries = file.entries.filter((e) => e.slug !== input.slug);
|
|
114
|
+
await deps.store.write(deps.sessionId, { version: 1, entries });
|
|
115
|
+
deps.registry.unregister(input.slug);
|
|
116
|
+
deps.emitPanelCards();
|
|
117
|
+
return { removed: true };
|
|
118
|
+
}
|