pi2web 0.2.0 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +209 -18
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
@@ -1,6 +1,9 @@
1
- import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
1
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { createCollabExtension } from './collab/pi-extension.js';
5
+ /** Older Pi builds resolved steer()/followUp() with undefined; treat anything but an explicit `handled` as queued. */
6
+ const normalizeDisposition = (value) => value === 'handled' ? 'handled' : 'queued';
4
7
  /** Real in-process Pi SDK backend with session/model/extension controls exposed to remote clients. */
5
8
  export class SdkBackend {
6
9
  session;
@@ -14,9 +17,38 @@ export class SdkBackend {
14
17
  this.agentId = agentId;
15
18
  this.cwd = cwd;
16
19
  }
17
- static async create(agentId, cwd, sessionFile) {
20
+ static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) {
18
21
  const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
19
- const { session, modelFallbackMessage } = await createAgentSession({ cwd, sessionManager });
22
+ const { template, piDefaultPrompt } = options;
23
+ // This factory is deliberately inline instead of auto-discovered: normal Pi and normal pi2web agents
24
+ // never load the collaboration tools or their collaboration-specific system guidance.
25
+ const extensionFactories = profile === 'collab' && collabBridge
26
+ ? [{ name: 'pi2web-collab', factory: createCollabExtension(agentId, collabBridge) }]
27
+ : [];
28
+ // A template behaves like Pi's own prompt files: `replace` stands in for SYSTEM.md, `append` follows
29
+ // APPEND_SYSTEM.md. `piDefaultPrompt` ignores both files to show what Pi itself would send.
30
+ const systemPromptOverride = piDefaultPrompt
31
+ ? () => undefined
32
+ : template?.mode === 'replace'
33
+ ? () => template.systemPrompt
34
+ : undefined;
35
+ const appendSystemPromptOverride = piDefaultPrompt
36
+ ? () => []
37
+ : template?.mode === 'append'
38
+ ? (base) => [...base, template.systemPrompt]
39
+ : undefined;
40
+ const resourceLoader = extensionFactories.length || systemPromptOverride || appendSystemPromptOverride
41
+ ? new DefaultResourceLoader({
42
+ cwd,
43
+ agentDir: getAgentDir(),
44
+ extensionFactories,
45
+ systemPromptOverride,
46
+ appendSystemPromptOverride
47
+ })
48
+ : undefined;
49
+ if (resourceLoader)
50
+ await resourceLoader.reload();
51
+ const { session, modelFallbackMessage } = await createAgentSession({ cwd, sessionManager, resourceLoader });
20
52
  if (modelFallbackMessage && !session.state.model) {
21
53
  session.dispose();
22
54
  throw Object.assign(new Error(modelFallbackMessage), { code: 'PI_MODEL_UNAVAILABLE' });
@@ -25,61 +57,268 @@ export class SdkBackend {
25
57
  await session.bindExtensions({ uiContext: backend.extensionUi() });
26
58
  return backend;
27
59
  }
28
- prompt(message) { return this.session.prompt(message); }
29
- steer(message) { return this.session.steer(message); }
30
- followUp(message) { return this.session.followUp(message); }
31
- abort() { return this.session.abort(); }
32
- async getState() { return { agentId: this.agentId, sessionId: this.session.sessionId, sessionFile: this.session.sessionFile, sessionName: this.session.sessionName, cwd: this.cwd, status: this.disposed ? 'stopped' : this.session.isStreaming ? 'streaming' : 'idle' }; }
33
- async getMessages() { return [...this.session.state.messages]; }
60
+ // Slash commands, skills and prompt templates are expanded by prompt() itself (expandPromptTemplates defaults to true),
61
+ // so `/name args` typed in the web composer behaves exactly like the Pi TUI.
62
+ prompt(message) {
63
+ return this.session.prompt(message);
64
+ }
65
+ // Since pi 0.99 steer()/followUp() report whether the message reached the running turn (`handled`)
66
+ // or waits for it (`queued`); the web composer shows that difference instead of guessing.
67
+ async steer(message) {
68
+ return normalizeDisposition(await this.session.steer(message));
69
+ }
70
+ async followUp(message) {
71
+ return normalizeDisposition(await this.session.followUp(message));
72
+ }
73
+ async getQueue() {
74
+ return {
75
+ steering: [...this.session.getSteeringMessages()],
76
+ followUp: [...this.session.getFollowUpMessages()]
77
+ };
78
+ }
79
+ /** Drop queued steering and follow-up messages and hand them back so a client can restore them to its composer. */
80
+ async clearQueue() {
81
+ const cleared = this.session.clearQueue();
82
+ return { steering: [...cleared.steering], followUp: [...cleared.followUp] };
83
+ }
84
+ abort() {
85
+ return this.session.abort();
86
+ }
87
+ async getState() {
88
+ return {
89
+ agentId: this.agentId,
90
+ sessionId: this.session.sessionId,
91
+ sessionFile: this.session.sessionFile,
92
+ sessionName: this.session.sessionName,
93
+ cwd: this.cwd,
94
+ status: this.disposed ? 'stopped' : this.session.isStreaming ? 'streaming' : 'idle'
95
+ };
96
+ }
97
+ timeline() {
98
+ const manager = this.session.sessionManager;
99
+ return {
100
+ sessionId: this.session.sessionId,
101
+ leafId: manager.getLeafId(),
102
+ entries: () => manager.getBranch()
103
+ };
104
+ }
105
+ /** The effective system prompt Pi will send next, plus the prompt files that contributed to it. */
106
+ async getSystemPrompt() {
107
+ const loader = this.session.resourceLoader;
108
+ return {
109
+ systemPrompt: this.session.systemPrompt,
110
+ systemFile: loader.getSystemPromptSource()?.path,
111
+ appendFiles: loader.getAppendSystemPromptSources().map(source => source.path),
112
+ contextFiles: loader.getAgentsFiles().agentsFiles.map(file => file.path)
113
+ };
114
+ }
34
115
  async getCapabilities() {
35
116
  const current = this.session.model;
36
- const available = this.session.modelRegistry.getAvailable();
37
- return { model: current ? this.modelInfo(current) : null, models: available.map(model => this.modelInfo(model)), thinkingLevel: this.session.thinkingLevel, thinkingLevels: this.session.getAvailableThinkingLevels(), supportsThinking: this.session.supportsThinking() };
38
- }
39
- async getSession() { return { ...await this.getSessionInfo(), leafId: this.session.sessionManager.getLeafId(), entries: this.session.sessionManager.getEntries(), tree: this.session.sessionManager.getTree(), userMessages: this.session.getUserMessagesForForking() }; }
40
- async getSessionInfo() { return { sessionId: this.session.sessionId, sessionFile: this.session.sessionFile, sessionName: this.session.sessionName, stats: this.session.getSessionStats(), contextUsage: this.session.getContextUsage() }; }
41
- compact(instructions) { return this.session.compact(instructions); }
42
- async setModel(provider, modelId) { const model = this.session.modelRegistry.find(provider, modelId); if (!model)
43
- throw Object.assign(new Error('Model not found'), { code: 'MODEL_NOT_FOUND' }); await this.session.setModel(model); }
44
- async setThinkingLevel(level) { if (!['off', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(level))
45
- throw Object.assign(new Error('Invalid thinking level'), { code: 'INVALID_THINKING_LEVEL' }); this.session.setThinkingLevel(level); }
46
- async setSessionName(name) { const value = name.trim(); if (!value || value.length > 200)
47
- throw Object.assign(new Error('Session name must be 1-200 characters'), { code: 'INVALID_SESSION_NAME' }); this.session.setSessionName(value); }
48
- navigate(entryId) { return this.session.navigateTree(entryId); }
49
- async fork(entryId) { const entry = this.session.sessionManager.getEntry(entryId); if (!entry || entry.type !== 'message' || entry.message?.role !== 'user')
50
- throw Object.assign(new Error('A user message entry is required'), { code: 'SESSION_ENTRY_NOT_FOUND' }); const content = entry.message.content; const selectedText = typeof content === 'string' ? content : Array.isArray(content) ? content.filter((part) => part.type === 'text').map((part) => part.text).join('\n') : ''; let sessionFile; if (entry.parentId) {
51
- sessionFile = this.session.sessionManager.createBranchedSession(entry.parentId);
52
- }
53
- else {
54
- const current = this.session.sessionFile;
55
- if (!current)
56
- throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
57
- sessionFile = SessionManager.create(this.cwd, this.session.sessionManager.getSessionDir(), { parentSession: current }).getSessionFile();
58
- } return { sessionFile, selectedText }; }
59
- async extensionResponse(requestId, value) { const pending = this.pendingUi.get(requestId); if (!pending)
60
- throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' }); clearTimeout(pending.timer); this.pendingUi.delete(requestId); pending.resolve(value); }
61
- subscribe(listener) { const fromSession = (event) => listener(event); const unsubscribe = this.session.subscribe(fromSession); this.events.on('event', listener); return () => { unsubscribe(); this.events.off('event', listener); }; }
62
- async dispose() { if (this.disposed)
63
- return; for (const [id, pending] of this.pendingUi) {
117
+ // ModelRuntime.getAvailable() is async since 0.80.8 and may consult provider auth; the snapshot keeps
118
+ // an unauthenticated or offline call from blocking the capabilities endpoint.
119
+ const available = await this.session.modelRuntime
120
+ .getAvailable()
121
+ .catch(() => this.session.modelRuntime.getAvailableSnapshot());
122
+ return {
123
+ model: current ? this.modelInfo(current) : null,
124
+ models: available.map((model) => this.modelInfo(model)),
125
+ thinkingLevel: this.session.thinkingLevel,
126
+ thinkingLevels: this.session.getAvailableThinkingLevels(),
127
+ supportsThinking: this.session.supportsThinking()
128
+ };
129
+ }
130
+ /** Slash commands the composer can offer: file-based prompt templates and skills discovered for this cwd. */
131
+ async listCommands() {
132
+ const loader = this.session.resourceLoader;
133
+ const prompts = loader.getPrompts().prompts.map(prompt => ({
134
+ name: prompt.name,
135
+ description: prompt.description,
136
+ argumentHint: prompt.argumentHint,
137
+ source: 'prompt'
138
+ }));
139
+ const skills = loader.getSkills().skills.map(skill => ({
140
+ name: `skill:${skill.name}`,
141
+ description: skill.description,
142
+ source: 'skill'
143
+ }));
144
+ return [...prompts, ...skills].sort((a, b) => a.name.localeCompare(b.name));
145
+ }
146
+ /** Run a command inside the Agent session so its output is recorded in the transcript (`!!` keeps it out of the model context). */
147
+ async runBash(command, excludeFromContext = false) {
148
+ const id = randomUUID();
149
+ this.events.emit('event', { type: 'bash_execution_start', id, command });
150
+ try {
151
+ const result = await this.session.executeBash(command, undefined, { excludeFromContext, id });
152
+ this.events.emit('event', { type: 'bash_execution_end', id, command, ...result });
153
+ return { id, ...result };
154
+ }
155
+ catch (error) {
156
+ this.events.emit('event', {
157
+ type: 'bash_execution_end',
158
+ id,
159
+ command,
160
+ isError: true,
161
+ errorMessage: error instanceof Error ? error.message : String(error)
162
+ });
163
+ throw error;
164
+ }
165
+ }
166
+ abortBash() {
167
+ this.session.abortBash();
168
+ }
169
+ async getSession() {
170
+ return {
171
+ ...(await this.getSessionInfo()),
172
+ leafId: this.session.sessionManager.getLeafId(),
173
+ entries: this.session.sessionManager.getEntries(),
174
+ tree: this.session.sessionManager.getTree(),
175
+ userMessages: this.session.getUserMessagesForForking()
176
+ };
177
+ }
178
+ async getSessionInfo() {
179
+ return {
180
+ sessionId: this.session.sessionId,
181
+ sessionFile: this.session.sessionFile,
182
+ sessionName: this.session.sessionName,
183
+ stats: this.session.getSessionStats(),
184
+ contextUsage: this.session.getContextUsage()
185
+ };
186
+ }
187
+ compact(instructions) {
188
+ return this.session.compact(instructions);
189
+ }
190
+ async setModel(provider, modelId) {
191
+ const model = this.session.modelRuntime.getModel(provider, modelId);
192
+ if (!model)
193
+ throw Object.assign(new Error('Model not found'), { code: 'MODEL_NOT_FOUND' });
194
+ await this.session.setModel(model);
195
+ }
196
+ async setThinkingLevel(level) {
197
+ if (level !== 'off' && !this.session.getAvailableThinkingLevels().includes(level))
198
+ throw Object.assign(new Error('Invalid thinking level'), { code: 'INVALID_THINKING_LEVEL' });
199
+ this.session.setThinkingLevel(level);
200
+ }
201
+ async setSessionName(name) {
202
+ const value = name.trim();
203
+ if (!value || value.length > 200)
204
+ throw Object.assign(new Error('Session name must be 1-200 characters'), { code: 'INVALID_SESSION_NAME' });
205
+ this.session.setSessionName(value);
206
+ }
207
+ navigate(entryId) {
208
+ return this.session.navigateTree(entryId);
209
+ }
210
+ async fork(entryId) {
211
+ const entry = this.session.sessionManager.getEntry(entryId);
212
+ if (!entry || entry.type !== 'message' || entry.message?.role !== 'user')
213
+ throw Object.assign(new Error('A user message entry is required'), { code: 'SESSION_ENTRY_NOT_FOUND' });
214
+ const content = entry.message.content;
215
+ const selectedText = typeof content === 'string'
216
+ ? content
217
+ : Array.isArray(content)
218
+ ? content
219
+ .filter((part) => part.type === 'text')
220
+ .map((part) => part.text)
221
+ .join('\n')
222
+ : '';
223
+ let sessionFile;
224
+ if (entry.parentId) {
225
+ sessionFile = this.session.sessionManager.createBranchedSession(entry.parentId);
226
+ }
227
+ else {
228
+ const current = this.session.sessionFile;
229
+ if (!current)
230
+ throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
231
+ sessionFile = SessionManager.create(this.cwd, this.session.sessionManager.getSessionDir(), {
232
+ parentSession: current
233
+ }).getSessionFile();
234
+ }
235
+ return { sessionFile, selectedText };
236
+ }
237
+ async extensionResponse(requestId, value) {
238
+ if (!this.resolveUiRequest(requestId, value))
239
+ throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' });
240
+ }
241
+ subscribe(listener) {
242
+ const fromSession = (event) => listener(event);
243
+ const unsubscribe = this.session.subscribe(fromSession);
244
+ this.events.on('event', listener);
245
+ return () => {
246
+ unsubscribe();
247
+ this.events.off('event', listener);
248
+ };
249
+ }
250
+ async dispose() {
251
+ if (this.disposed)
252
+ return;
253
+ for (const requestId of this.pendingUi.keys())
254
+ this.resolveUiRequest(requestId, undefined);
255
+ await this.session.abort();
256
+ this.session.dispose();
257
+ this.disposed = true;
258
+ }
259
+ modelInfo(model) {
260
+ return {
261
+ provider: model.provider,
262
+ id: model.id,
263
+ name: model.name,
264
+ reasoning: !!model.reasoning,
265
+ contextWindow: model.contextWindow,
266
+ maxTokens: model.maxTokens
267
+ };
268
+ }
269
+ resolveUiRequest(requestId, value) {
270
+ const pending = this.pendingUi.get(requestId);
271
+ if (!pending)
272
+ return false;
64
273
  clearTimeout(pending.timer);
65
- pending.resolve(undefined);
66
- this.pendingUi.delete(id);
67
- } await this.session.abort(); this.session.dispose(); this.disposed = true; }
68
- modelInfo(model) { return { provider: model.provider, id: model.id, name: model.name, reasoning: !!model.reasoning, contextWindow: model.contextWindow, maxTokens: model.maxTokens }; }
69
- request(kind, payload) { const requestId = randomUUID(); return new Promise(resolve => { const timer = setTimeout(() => { this.pendingUi.delete(requestId); resolve(undefined); }, 5 * 60_000); this.pendingUi.set(requestId, { resolve, timer }); this.events.emit('event', { type: 'extension_ui_request', requestId, kind, ...payload }); }); }
274
+ this.pendingUi.delete(requestId);
275
+ // Publish before resuming the extension: every client sees the same order,
276
+ // including requests answered elsewhere, timed out, or closed during disposal.
277
+ this.events.emit('event', { type: 'extension_ui_response', requestId });
278
+ pending.resolve(value);
279
+ return true;
280
+ }
281
+ request(kind, payload) {
282
+ const requestId = randomUUID();
283
+ return new Promise(resolve => {
284
+ const timer = setTimeout(() => this.resolveUiRequest(requestId, undefined), 5 * 60_000);
285
+ this.pendingUi.set(requestId, { resolve, timer });
286
+ this.events.emit('event', { type: 'extension_ui_request', requestId, kind, ...payload });
287
+ });
288
+ }
70
289
  extensionUi() {
71
290
  return {
72
- select: async (title, options, opts) => await this.request('select', { title, options, opts }),
291
+ select: async (title, options, opts) => (await this.request('select', { title, options, opts })),
73
292
  confirm: async (title, message, opts) => Boolean(await this.request('confirm', { title, message, opts })),
74
- input: async (title, placeholder, opts) => await this.request('input', { title, placeholder, opts }),
75
- editor: async (title, prefill) => await this.request('editor', { title, prefill }),
293
+ input: async (title, placeholder, opts) => (await this.request('input', { title, placeholder, opts })),
294
+ editor: async (title, prefill) => (await this.request('editor', { title, prefill })),
76
295
  notify: (message, type) => this.events.emit('event', { type: 'extension_ui_notify', message, notificationType: type ?? 'info' }),
77
296
  setStatus: (key, text) => this.events.emit('event', { type: 'extension_ui_status', key, text }),
78
- setWidget: (key, content, options) => this.events.emit('event', { type: 'extension_ui_widget', key, content: Array.isArray(content) ? content : undefined, options }),
297
+ setWidget: (key, content, options) => this.events.emit('event', {
298
+ type: 'extension_ui_widget',
299
+ key,
300
+ content: Array.isArray(content) ? content : undefined,
301
+ options
302
+ }),
79
303
  setTitle: (title) => this.events.emit('event', { type: 'extension_ui_title', title }),
80
- setWorkingMessage: (message) => this.events.emit('event', { type: 'extension_ui_working_message', message }), setWorkingVisible: () => { }, setWorkingIndicator: () => { }, setHiddenThinkingLabel: () => { },
81
- onTerminalInput: () => () => { }, setFooter: () => { }, setHeader: () => { }, pasteToEditor: () => { }, setEditorText: () => { }, getEditorText: () => '', addAutocompleteProvider: () => { }, setEditorComponent: () => { }, custom: async () => { throw Object.assign(new Error('Custom extension components are unsupported'), { code: 'EXTENSION_CUSTOM_UI_UNSUPPORTED' }); }
304
+ setWorkingMessage: (message) => this.events.emit('event', { type: 'extension_ui_working_message', message }),
305
+ setWorkingVisible: () => { },
306
+ setWorkingIndicator: () => { },
307
+ setHiddenThinkingLabel: () => { },
308
+ onTerminalInput: () => () => { },
309
+ setFooter: () => { },
310
+ setHeader: () => { },
311
+ pasteToEditor: () => { },
312
+ setEditorText: () => { },
313
+ getEditorText: () => '',
314
+ addAutocompleteProvider: () => { },
315
+ setEditorComponent: () => { },
316
+ custom: async () => {
317
+ throw Object.assign(new Error('Custom extension components are unsupported'), {
318
+ code: 'EXTENSION_CUSTOM_UI_UNSUPPORTED'
319
+ });
320
+ }
82
321
  };
83
322
  }
84
323
  }
85
- export const createSdkBackend = (agentId, cwd, sessionFile) => SdkBackend.create(agentId, cwd, sessionFile);
324
+ export const createSdkBackend = (agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) => SdkBackend.create(agentId, cwd, sessionFile, profile, collabBridge, options);