@pimote/pimote 0.3.1 → 0.4.1
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/{CklMSqcv.js → B-E0ZvqP.js} +1 -1
- package/client/build/_app/immutable/chunks/B8_nBK84.js +1 -0
- package/client/build/_app/immutable/chunks/BXTn4iTX.js +5 -0
- package/client/build/_app/immutable/chunks/_NcRSVb1.js +1 -0
- package/client/build/_app/immutable/chunks/wFL3btjl.js +1 -0
- package/client/build/_app/immutable/entry/{app.B-HFVtpC.js → app.B5rNDlzR.js} +2 -2
- package/client/build/_app/immutable/entry/start.Ge2L0aip.js +1 -0
- package/client/build/_app/immutable/nodes/0.DncQszfo.js +10 -0
- package/client/build/_app/immutable/nodes/{1.CmxFYjRm.js → 1.Bm4mW4cF.js} +1 -1
- package/client/build/_app/immutable/nodes/2.CIMIcssN.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 +21 -7
- 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 +121 -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 +120 -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 +24 -1
- 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/CO_BwWGt.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/config.js
CHANGED
|
@@ -69,7 +69,6 @@ function parseVoiceConfig(v) {
|
|
|
69
69
|
return undefined;
|
|
70
70
|
const o = v;
|
|
71
71
|
return {
|
|
72
|
-
speechmuxBinary: typeof o.speechmuxBinary === 'string' ? o.speechmuxBinary : undefined,
|
|
73
72
|
speechmuxSignalUrl: typeof o.speechmuxSignalUrl === 'string' ? o.speechmuxSignalUrl : undefined,
|
|
74
73
|
speechmuxLlmWsUrl: typeof o.speechmuxLlmWsUrl === 'string' ? o.speechmuxLlmWsUrl : undefined,
|
|
75
74
|
};
|
|
@@ -144,9 +144,26 @@ export function createExtensionUIBridge(slot, pushNotificationService, options)
|
|
|
144
144
|
setWorkingMessage() {
|
|
145
145
|
// no-op
|
|
146
146
|
},
|
|
147
|
+
setWorkingVisible() {
|
|
148
|
+
// TODO(pimote-bridge): forward to client so extensions can hide/show the working indicator row.
|
|
149
|
+
// Web client renders its own streaming UI; no-op is safe but loses extension control.
|
|
150
|
+
},
|
|
151
|
+
setWorkingIndicator() {
|
|
152
|
+
// TODO(pimote-bridge): forward custom working-indicator frames/colors to client renderer.
|
|
153
|
+
// Web client renders its own spinner; no-op is safe but loses extension control.
|
|
154
|
+
},
|
|
147
155
|
setHiddenThinkingLabel() {
|
|
148
156
|
// no-op
|
|
149
157
|
},
|
|
158
|
+
addAutocompleteProvider() {
|
|
159
|
+
// TODO(pimote-bridge): wire extension autocomplete factory into the client autocomplete stack.
|
|
160
|
+
// Today the client only has built-in slash/path completion; extension providers are dropped.
|
|
161
|
+
},
|
|
162
|
+
getEditorComponent() {
|
|
163
|
+
// TODO(pimote-bridge): decide whether to surface a stub EditorFactory for chained extension editors.
|
|
164
|
+
// Web client has no TUI editor component to wrap, so undefined is the honest answer.
|
|
165
|
+
return undefined;
|
|
166
|
+
},
|
|
150
167
|
setFooter() {
|
|
151
168
|
// no-op
|
|
152
169
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, stat, unlink } from 'node:fs/promises';
|
|
2
2
|
import { join, basename } from 'node:path';
|
|
3
|
-
import { SessionManager } from '@
|
|
3
|
+
import { SessionManager } from '@earendil-works/pi-coding-agent';
|
|
4
4
|
/** Project marker files/directories that identify a folder as a project. */
|
|
5
5
|
const PROJECT_MARKERS = ['.git', 'package.json'];
|
|
6
6
|
/**
|
package/server/dist/index.js
CHANGED
|
@@ -6,9 +6,10 @@ import { PimoteSessionManager } from './session-manager.js';
|
|
|
6
6
|
import { FolderIndex } from './folder-index.js';
|
|
7
7
|
import { PushNotificationService } from './push-notification.js';
|
|
8
8
|
import { FilePushSubscriptionStore, WebPushSender, migratePushSubscriptionStore } from './push-infrastructure.js';
|
|
9
|
-
import { LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_SESSION_METADATA_PATH } from './paths.js';
|
|
9
|
+
import { LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_SESSION_METADATA_PATH, PIMOTE_STATIC_HOST_DIR } from './paths.js';
|
|
10
10
|
import { FileSessionMetadataStore } from './session-metadata.js';
|
|
11
11
|
import { buildVoiceOrchestrator } from './voice-orchestrator-boot.js';
|
|
12
|
+
import { InMemoryStaticHostRegistry, FileStaticHostStore, gcStaticHostStore, createStaticHostExtension } from './static-host/index.js';
|
|
12
13
|
export async function main(options = {}) {
|
|
13
14
|
let config = await loadConfig();
|
|
14
15
|
config = await ensureVapidKeys(config);
|
|
@@ -23,7 +24,33 @@ export async function main(options = {}) {
|
|
|
23
24
|
await pushNotificationService.initialize();
|
|
24
25
|
const sessionMetadataStore = new FileSessionMetadataStore(PIMOTE_SESSION_METADATA_PATH);
|
|
25
26
|
await sessionMetadataStore.initialize();
|
|
26
|
-
|
|
27
|
+
// Static-host bootstrap: GC orphan persistence files, then construct the
|
|
28
|
+
// registry/store/factory singletons shared by the session manager and the
|
|
29
|
+
// HTTP route handler. The registry is process-lifetime; sessions register
|
|
30
|
+
// and unregister against it as they load and shut down.
|
|
31
|
+
let validSessionIds = new Set();
|
|
32
|
+
try {
|
|
33
|
+
const folders = await folderIndex.scan();
|
|
34
|
+
for (const folder of folders) {
|
|
35
|
+
const records = await folderIndex.listSessionRecords(folder.path);
|
|
36
|
+
for (const rec of records)
|
|
37
|
+
validSessionIds.add(rec.id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// Critical: do NOT run GC with an empty allow-list — that would delete
|
|
42
|
+
// every persisted bundle on a transient I/O hiccup at boot. Skip the
|
|
43
|
+
// sweep entirely and let the next clean boot reclaim orphans.
|
|
44
|
+
console.warn('[pimote] static-host GC: failed to enumerate sessions, skipping sweep this boot', err);
|
|
45
|
+
validSessionIds = null;
|
|
46
|
+
}
|
|
47
|
+
if (validSessionIds) {
|
|
48
|
+
await gcStaticHostStore({ storeDir: PIMOTE_STATIC_HOST_DIR, validSessionIds });
|
|
49
|
+
}
|
|
50
|
+
const staticHostRegistry = new InMemoryStaticHostRegistry();
|
|
51
|
+
const staticHostStore = new FileStaticHostStore(PIMOTE_STATIC_HOST_DIR);
|
|
52
|
+
const staticHostFactory = createStaticHostExtension({ registry: staticHostRegistry, store: staticHostStore });
|
|
53
|
+
const sessionManager = new PimoteSessionManager(config, pushNotificationService, { staticHostFactory });
|
|
27
54
|
// Build the voice orchestrator before createServer so each WsHandler can be
|
|
28
55
|
// handed a reference. The orchestrator needs a client-registry lookup, but
|
|
29
56
|
// the real registry is created inside createServer below — so we hand it a
|
|
@@ -37,27 +64,32 @@ export async function main(options = {}) {
|
|
|
37
64
|
get: (clientId) => clientRegistryRef.current.get(clientId),
|
|
38
65
|
},
|
|
39
66
|
});
|
|
40
|
-
|
|
67
|
+
if (!voiceBoot) {
|
|
68
|
+
console.log('[voice] dormant: voice config absent (set voice.speechmuxSignalUrl and voice.speechmuxLlmWsUrl to enable)');
|
|
69
|
+
}
|
|
70
|
+
const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceBoot?.orchestrator, staticHostRegistry);
|
|
41
71
|
clientRegistryRef.current = server.clientRegistry;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
if (voiceBoot) {
|
|
73
|
+
const orchestrator = voiceBoot.orchestrator;
|
|
74
|
+
// Suppress push notifications for sessions currently owned by a voice call.
|
|
75
|
+
// The user is on the line — we don't need to ping their phone for idle
|
|
76
|
+
// signals or extension UI prompts. Pushes resume automatically once the
|
|
77
|
+
// call ends and `isCallActive` flips back to false.
|
|
78
|
+
pushNotificationService.setSuppressionPredicate((sessionId) => orchestrator.isCallActive(sessionId));
|
|
79
|
+
// Tear down orchestrator bookkeeping when a session is being closed (idle
|
|
80
|
+
// reap, explicit close). Emits call_ended{server_ended} to the owner.
|
|
81
|
+
sessionManager.onBeforeSessionClose = async (sessionId) => {
|
|
82
|
+
if (!orchestrator.isCallActive(sessionId))
|
|
83
|
+
return;
|
|
84
|
+
const slot = sessionManager.getSlot(sessionId);
|
|
85
|
+
const ownerClientId = slot?.connection?.connectedClientId;
|
|
86
|
+
await orchestrator.endCall({ sessionId, reason: 'server_ended' });
|
|
87
|
+
if (ownerClientId) {
|
|
88
|
+
const handler = server.clientRegistry.get(ownerClientId);
|
|
89
|
+
handler?.sendCallEndedEvent(sessionId, 'server_ended');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
61
93
|
// Start idle session reaping with client connectivity check
|
|
62
94
|
sessionManager.startIdleCheck(config.idleTimeout, (clientId) => server.clientRegistry.has(clientId));
|
|
63
95
|
await server.start(port);
|
|
@@ -70,7 +102,7 @@ export async function main(options = {}) {
|
|
|
70
102
|
// Graceful shutdown
|
|
71
103
|
const shutdown = async () => {
|
|
72
104
|
console.log('\n[pimote] Shutting down...');
|
|
73
|
-
await voiceBoot
|
|
105
|
+
await voiceBoot?.shutdown();
|
|
74
106
|
await sessionManager.dispose();
|
|
75
107
|
await server.close();
|
|
76
108
|
process.exit(0);
|
|
@@ -171,5 +171,6 @@ export function mapAgentMessage(msg) {
|
|
|
171
171
|
content,
|
|
172
172
|
...(msg.id ? { entryId: msg.id } : {}),
|
|
173
173
|
...(aborted ? { aborted: true } : {}),
|
|
174
|
+
...(role === 'assistant' && typeof msg.errorMessage === 'string' ? { errorMessage: msg.errorMessage } : {}),
|
|
174
175
|
};
|
|
175
176
|
}
|
package/server/dist/paths.js
CHANGED
|
@@ -11,4 +11,6 @@ export const PIMOTE_STATE_DIR = join(getXdgStateHome(), 'pimote');
|
|
|
11
11
|
export const PIMOTE_CONFIG_PATH = join(PIMOTE_CONFIG_DIR, 'config.json');
|
|
12
12
|
export const PIMOTE_PUSH_SUBSCRIPTIONS_PATH = join(PIMOTE_STATE_DIR, 'push-subscriptions.json');
|
|
13
13
|
export const PIMOTE_SESSION_METADATA_PATH = join(PIMOTE_STATE_DIR, 'session-metadata.json');
|
|
14
|
+
/** Directory holding per-session static-host persistence files (`<sessionId>.json`). */
|
|
15
|
+
export const PIMOTE_STATIC_HOST_DIR = join(PIMOTE_STATE_DIR, 'static-host');
|
|
14
16
|
export const LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH = join(PIMOTE_CONFIG_DIR, 'push-subscriptions.json');
|
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';
|
|
@@ -119,7 +119,13 @@ function setupSlotPanelListeners(eventBus, state, sessionId, sendEvent) {
|
|
|
119
119
|
applyPanelMessage(state.panelState, data);
|
|
120
120
|
scheduleSlotPanelPush(state, sessionId, sendEvent);
|
|
121
121
|
});
|
|
122
|
-
|
|
122
|
+
const unsub3 = eventBus.on('pimote:navigate', (data) => {
|
|
123
|
+
const url = data?.url;
|
|
124
|
+
if (typeof url !== 'string' || url.length === 0)
|
|
125
|
+
return;
|
|
126
|
+
sendEvent({ type: 'pimote_navigate', sessionId, url });
|
|
127
|
+
});
|
|
128
|
+
return [unsub1, unsub2, unsub3];
|
|
123
129
|
}
|
|
124
130
|
/** Schedule a throttled panel push (~200ms) for a SessionState. */
|
|
125
131
|
function scheduleSlotPanelPush(state, sessionId, sendEvent) {
|
|
@@ -147,19 +153,25 @@ export class PimoteSessionManager {
|
|
|
147
153
|
* reap, explicit close). Consumers use this to drop external bookkeeping
|
|
148
154
|
* (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
|
|
149
155
|
onBeforeSessionClose;
|
|
150
|
-
|
|
156
|
+
staticHostFactory;
|
|
157
|
+
constructor(config, pushNotificationService, options = {}) {
|
|
151
158
|
this.config = config;
|
|
152
159
|
this.pushNotificationService = pushNotificationService;
|
|
153
160
|
this.authStorage = AuthStorage.create();
|
|
154
161
|
this.modelRegistry = ModelRegistry.create(this.authStorage);
|
|
162
|
+
this.staticHostFactory = options.staticHostFactory;
|
|
155
163
|
}
|
|
156
164
|
/**
|
|
157
165
|
* Build the voice extension factory for this server's config, if possible.
|
|
158
|
-
* Returns undefined
|
|
159
|
-
* refs nor fallback defaultProvider/
|
|
160
|
-
*
|
|
166
|
+
* Returns undefined when voice is not configured (no speechmux URLs) or
|
|
167
|
+
* when neither voice-specific model refs nor fallback defaultProvider/
|
|
168
|
+
* defaultModel are configured. Non-voice deployments continue to work
|
|
169
|
+
* unchanged — sessions simply don't load `@pimote/voice` at all.
|
|
161
170
|
*/
|
|
162
171
|
buildVoiceExtensionFactory() {
|
|
172
|
+
if (!this.config.voice?.speechmuxSignalUrl || !this.config.voice?.speechmuxLlmWsUrl) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
163
175
|
const interpreter = this.config.defaultInterpreterModel ??
|
|
164
176
|
(this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
|
|
165
177
|
const worker = this.config.defaultWorkerModel ??
|
|
@@ -180,6 +192,8 @@ export class PimoteSessionManager {
|
|
|
180
192
|
const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
|
|
181
193
|
const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
|
|
182
194
|
const voiceExtensionFactory = this.buildVoiceExtensionFactory();
|
|
195
|
+
const staticHostFactory = this.staticHostFactory;
|
|
196
|
+
const extensionFactories = [...(voiceExtensionFactory ? [voiceExtensionFactory] : []), ...(staticHostFactory ? [staticHostFactory] : [])];
|
|
183
197
|
const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
184
198
|
const eventBus = createEventBus();
|
|
185
199
|
eventBusRef.current = eventBus;
|
|
@@ -190,7 +204,7 @@ export class PimoteSessionManager {
|
|
|
190
204
|
modelRegistry: sharedModelRegistry,
|
|
191
205
|
resourceLoaderOptions: {
|
|
192
206
|
eventBus,
|
|
193
|
-
...(
|
|
207
|
+
...(extensionFactories.length ? { extensionFactories } : {}),
|
|
194
208
|
},
|
|
195
209
|
});
|
|
196
210
|
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,121 @@
|
|
|
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 emitNavigate(pi, url) {
|
|
48
|
+
pi.events.emit('pimote:navigate', { url });
|
|
49
|
+
}
|
|
50
|
+
function toolDeps(pi, sessionId) {
|
|
51
|
+
return {
|
|
52
|
+
registry,
|
|
53
|
+
store,
|
|
54
|
+
sessionId,
|
|
55
|
+
emitPanelCards: () => emitPanelCards(pi, sessionId),
|
|
56
|
+
emitNavigate: (url) => emitNavigate(pi, url),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return (pi) => {
|
|
60
|
+
pi.registerTool({
|
|
61
|
+
name: 'pimote_static_host',
|
|
62
|
+
label: 'Host static bundle',
|
|
63
|
+
description: STATIC_HOST_TOOL_DESCRIPTION,
|
|
64
|
+
parameters: Type.Object({
|
|
65
|
+
slug: Type.String({ description: 'Short URL slug, lowercase [a-z0-9-]+ with no leading/trailing dash.' }),
|
|
66
|
+
folder: Type.String({ description: 'Absolute path to the folder containing the bundle (must contain index.html).' }),
|
|
67
|
+
title: Type.String({ description: 'Title displayed on the panel card.' }),
|
|
68
|
+
tag: Type.Optional(Type.String({ description: 'Optional short tag shown next to the title.' })),
|
|
69
|
+
color: Type.Optional(Type.Union([Type.Literal('accent'), Type.Literal('success'), Type.Literal('warning'), Type.Literal('error'), Type.Literal('muted')], {
|
|
70
|
+
description: 'Optional card color.',
|
|
71
|
+
})),
|
|
72
|
+
}),
|
|
73
|
+
execute: async (_callId, input, _abort, _meta, ctx) => {
|
|
74
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
75
|
+
const out = await executeRegisterTool(input, toolDeps(pi, sessionId));
|
|
76
|
+
return { content: [{ type: 'text', text: JSON.stringify(out) }], details: out };
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
pi.registerTool({
|
|
80
|
+
name: 'pimote_static_host_remove',
|
|
81
|
+
label: 'Remove hosted bundle',
|
|
82
|
+
description: 'Unregister a previously hosted static bundle by slug.',
|
|
83
|
+
parameters: Type.Object({
|
|
84
|
+
slug: Type.String({ description: 'Slug of the bundle to remove.' }),
|
|
85
|
+
}),
|
|
86
|
+
execute: async (_callId, input, _abort, _meta, ctx) => {
|
|
87
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
88
|
+
const out = await executeRemoveTool(input, toolDeps(pi, sessionId));
|
|
89
|
+
return { content: [{ type: 'text', text: JSON.stringify(out) }], details: out };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
pi.on('session_start', async (_ev, ctx) => {
|
|
93
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
94
|
+
const file = await store.read(sessionId);
|
|
95
|
+
if (!file)
|
|
96
|
+
return;
|
|
97
|
+
for (const entry of file.entries) {
|
|
98
|
+
try {
|
|
99
|
+
registry.register({
|
|
100
|
+
slug: entry.slug,
|
|
101
|
+
folderPath: entry.folderPath,
|
|
102
|
+
sessionId,
|
|
103
|
+
cardMetadata: entry.cardMetadata,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
// Defensive: a slug conflict (e.g. two sessions persisted the same
|
|
108
|
+
// slug, or another session reloaded earlier this boot) must not
|
|
109
|
+
// abort the whole replay loop and leave the session partially
|
|
110
|
+
// loaded. Skip the conflicting entry and continue.
|
|
111
|
+
console.warn(`[static-host] session_start: skipping persisted entry ${entry.slug} for session ${sessionId}`, err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
emitPanelCards(pi, sessionId);
|
|
115
|
+
});
|
|
116
|
+
pi.on('session_shutdown', async (_ev, ctx) => {
|
|
117
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
118
|
+
registry.unregisterAllForSession(sessionId);
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -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');
|