pi2web 0.2.1 → 0.2.8

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 (54) hide show
  1. package/README.md +53 -6
  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 +671 -152
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +222 -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 +47 -0
  25. package/dist/http/web-assets.js +64 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +203 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +249 -45
  33. package/dist/server.js +488 -614
  34. package/dist/session-namer.js +66 -17
  35. package/dist/session-performance.js +137 -0
  36. package/dist/sotto-stt.js +217 -0
  37. package/dist/terminals.js +94 -27
  38. package/dist/timeline.js +185 -0
  39. package/dist/voice.js +153 -35
  40. package/dist/workspaces.js +91 -26
  41. package/dist/ws/agent-socket.js +256 -0
  42. package/dist/ws/terminal-socket.js +46 -0
  43. package/npm-shrinkwrap.json +3685 -0
  44. package/package.json +11 -1
  45. package/packages/protocol/openapi.yaml +24 -2
  46. package/packages/protocol/websocket.schema.json +58 -15
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3647 -756
  49. package/web/backend-state.js +59 -27
  50. package/web/collab.js +684 -257
  51. package/web/hid-keyboard.js +173 -140
  52. package/web/index.html +7 -2
  53. package/web/styles.css +21 -2
  54. package/web/voice-pcm.js +36 -0
@@ -1,6 +1,7 @@
1
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 { PERFORMANCE_ENTRY, SessionPerformanceTracker } from './session-performance.js';
4
5
  import { createCollabExtension } from './collab/pi-extension.js';
5
6
  /** Older Pi builds resolved steer()/followUp() with undefined; treat anything but an explicit `handled` as queued. */
6
7
  const normalizeDisposition = (value) => value === 'handled' ? 'handled' : 'queued';
@@ -16,12 +17,38 @@ export class SdkBackend {
16
17
  this.session = session;
17
18
  this.agentId = agentId;
18
19
  this.cwd = cwd;
20
+ const performance = new SessionPerformanceTracker(run => this.session.sessionManager.appendCustomEntry(PERFORMANCE_ENTRY, run));
21
+ this.session.subscribe(event => performance.apply(event));
19
22
  }
20
- static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge) {
23
+ static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) {
21
24
  const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
25
+ const { template, piDefaultPrompt } = options;
22
26
  // This factory is deliberately inline instead of auto-discovered: normal Pi and normal pi2web agents
23
27
  // never load the collaboration tools or their collaboration-specific system guidance.
24
- const resourceLoader = profile === 'collab' && collabBridge ? new DefaultResourceLoader({ cwd, agentDir: getAgentDir(), extensionFactories: [{ name: 'pi2web-collab', factory: createCollabExtension(agentId, collabBridge) }] }) : undefined;
28
+ const extensionFactories = profile === 'collab' && collabBridge
29
+ ? [{ name: 'pi2web-collab', factory: createCollabExtension(agentId, collabBridge) }]
30
+ : [];
31
+ // A template behaves like Pi's own prompt files: `replace` stands in for SYSTEM.md, `append` follows
32
+ // APPEND_SYSTEM.md. `piDefaultPrompt` ignores both files to show what Pi itself would send.
33
+ const systemPromptOverride = piDefaultPrompt
34
+ ? () => undefined
35
+ : template?.mode === 'replace'
36
+ ? () => template.systemPrompt
37
+ : undefined;
38
+ const appendSystemPromptOverride = piDefaultPrompt
39
+ ? () => []
40
+ : template?.mode === 'append'
41
+ ? (base) => [...base, template.systemPrompt]
42
+ : undefined;
43
+ const resourceLoader = extensionFactories.length || systemPromptOverride || appendSystemPromptOverride
44
+ ? new DefaultResourceLoader({
45
+ cwd,
46
+ agentDir: getAgentDir(),
47
+ extensionFactories,
48
+ systemPromptOverride,
49
+ appendSystemPromptOverride
50
+ })
51
+ : undefined;
25
52
  if (resourceLoader)
26
53
  await resourceLoader.reload();
27
54
  const { session, modelFallbackMessage } = await createAgentSession({ cwd, sessionManager, resourceLoader });
@@ -35,29 +62,113 @@ export class SdkBackend {
35
62
  }
36
63
  // Slash commands, skills and prompt templates are expanded by prompt() itself (expandPromptTemplates defaults to true),
37
64
  // so `/name args` typed in the web composer behaves exactly like the Pi TUI.
38
- prompt(message) { return this.session.prompt(message); }
65
+ prompt(message) {
66
+ return this.session.prompt(message);
67
+ }
39
68
  // Since pi 0.99 steer()/followUp() report whether the message reached the running turn (`handled`)
40
69
  // or waits for it (`queued`); the web composer shows that difference instead of guessing.
41
- async steer(message) { return normalizeDisposition(await this.session.steer(message)); }
42
- async followUp(message) { return normalizeDisposition(await this.session.followUp(message)); }
43
- async getQueue() { return { steering: [...this.session.getSteeringMessages()], followUp: [...this.session.getFollowUpMessages()] }; }
70
+ async steer(message) {
71
+ return normalizeDisposition(await this.session.steer(message));
72
+ }
73
+ async followUp(message) {
74
+ return normalizeDisposition(await this.session.followUp(message));
75
+ }
76
+ async getQueue() {
77
+ return {
78
+ steering: [...this.session.getSteeringMessages()],
79
+ followUp: [...this.session.getFollowUpMessages()]
80
+ };
81
+ }
44
82
  /** Drop queued steering and follow-up messages and hand them back so a client can restore them to its composer. */
45
- async clearQueue() { const cleared = this.session.clearQueue(); return { steering: [...cleared.steering], followUp: [...cleared.followUp] }; }
46
- abort() { return this.session.abort(); }
47
- 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' }; }
48
- async getMessages() { return [...this.session.state.messages]; }
83
+ async clearQueue() {
84
+ const cleared = this.session.clearQueue();
85
+ return { steering: [...cleared.steering], followUp: [...cleared.followUp] };
86
+ }
87
+ async moveQueue(from, index, expected) {
88
+ const before = {
89
+ steering: [...this.session.getSteeringMessages()],
90
+ followUp: [...this.session.getFollowUpMessages()]
91
+ };
92
+ if (!this.session.isStreaming || before[from][index] !== expected)
93
+ throw Object.assign(new Error('Queued message has changed or already been consumed'), {
94
+ code: 'QUEUE_CHANGED'
95
+ });
96
+ const to = from === 'steering' ? 'followUp' : 'steering';
97
+ before[from].splice(index, 1);
98
+ before[to].push(expected);
99
+ if ([...before.steering, ...before.followUp].some(text => text.startsWith('/')))
100
+ throw Object.assign(new Error('Slash commands cannot be moved safely'), { code: 'QUEUE_CHANGED' });
101
+ // Pi exposes clearQueue(), but no per-message move. Clear and requeue in the same JS turn:
102
+ // steer()/followUp() enqueue synchronously before their promises settle. No model callback can
103
+ // drain the half-updated queue between these calls. Never replace a stale queue by index alone.
104
+ this.session.clearQueue();
105
+ const pending = [
106
+ ...before.steering.map(text => this.session.steer(text)),
107
+ ...before.followUp.map(text => this.session.followUp(text))
108
+ ];
109
+ await Promise.all(pending);
110
+ return this.getQueue();
111
+ }
112
+ abort() {
113
+ return this.session.abort();
114
+ }
115
+ async getState() {
116
+ return {
117
+ agentId: this.agentId,
118
+ sessionId: this.session.sessionId,
119
+ sessionFile: this.session.sessionFile,
120
+ sessionName: this.session.sessionName,
121
+ cwd: this.cwd,
122
+ status: this.disposed ? 'stopped' : this.session.isStreaming ? 'streaming' : 'idle'
123
+ };
124
+ }
125
+ timeline() {
126
+ const manager = this.session.sessionManager;
127
+ return {
128
+ sessionId: this.session.sessionId,
129
+ leafId: manager.getLeafId(),
130
+ entries: () => manager.getBranch()
131
+ };
132
+ }
133
+ /** The effective system prompt Pi will send next, plus the prompt files that contributed to it. */
134
+ async getSystemPrompt() {
135
+ const loader = this.session.resourceLoader;
136
+ return {
137
+ systemPrompt: this.session.systemPrompt,
138
+ systemFile: loader.getSystemPromptSource()?.path,
139
+ appendFiles: loader.getAppendSystemPromptSources().map(source => source.path),
140
+ contextFiles: loader.getAgentsFiles().agentsFiles.map(file => file.path)
141
+ };
142
+ }
49
143
  async getCapabilities() {
50
144
  const current = this.session.model;
51
145
  // ModelRuntime.getAvailable() is async since 0.80.8 and may consult provider auth; the snapshot keeps
52
146
  // an unauthenticated or offline call from blocking the capabilities endpoint.
53
- const available = await this.session.modelRuntime.getAvailable().catch(() => this.session.modelRuntime.getAvailableSnapshot());
54
- 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() };
147
+ const available = await this.session.modelRuntime
148
+ .getAvailable()
149
+ .catch(() => this.session.modelRuntime.getAvailableSnapshot());
150
+ return {
151
+ model: current ? this.modelInfo(current) : null,
152
+ models: available.map((model) => this.modelInfo(model)),
153
+ thinkingLevel: this.session.thinkingLevel,
154
+ thinkingLevels: this.session.getAvailableThinkingLevels(),
155
+ supportsThinking: this.session.supportsThinking()
156
+ };
55
157
  }
56
158
  /** Slash commands the composer can offer: file-based prompt templates and skills discovered for this cwd. */
57
159
  async listCommands() {
58
160
  const loader = this.session.resourceLoader;
59
- const prompts = loader.getPrompts().prompts.map(prompt => ({ name: prompt.name, description: prompt.description, argumentHint: prompt.argumentHint, source: 'prompt' }));
60
- const skills = loader.getSkills().skills.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, source: 'skill' }));
161
+ const prompts = loader.getPrompts().prompts.map(prompt => ({
162
+ name: prompt.name,
163
+ description: prompt.description,
164
+ argumentHint: prompt.argumentHint,
165
+ source: 'prompt'
166
+ }));
167
+ const skills = loader.getSkills().skills.map(skill => ({
168
+ name: `skill:${skill.name}`,
169
+ description: skill.description,
170
+ source: 'skill'
171
+ }));
61
172
  return [...prompts, ...skills].sort((a, b) => a.name.localeCompare(b.name));
62
173
  }
63
174
  /** Run a command inside the Agent session so its output is recorded in the transcript (`!!` keeps it out of the model context). */
@@ -70,36 +181,100 @@ export class SdkBackend {
70
181
  return { id, ...result };
71
182
  }
72
183
  catch (error) {
73
- this.events.emit('event', { type: 'bash_execution_end', id, command, isError: true, errorMessage: error instanceof Error ? error.message : String(error) });
184
+ this.events.emit('event', {
185
+ type: 'bash_execution_end',
186
+ id,
187
+ command,
188
+ isError: true,
189
+ errorMessage: error instanceof Error ? error.message : String(error)
190
+ });
74
191
  throw error;
75
192
  }
76
193
  }
77
- abortBash() { this.session.abortBash(); }
78
- 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() }; }
79
- async getSessionInfo() { return { sessionId: this.session.sessionId, sessionFile: this.session.sessionFile, sessionName: this.session.sessionName, stats: this.session.getSessionStats(), contextUsage: this.session.getContextUsage() }; }
80
- compact(instructions) { return this.session.compact(instructions); }
81
- async setModel(provider, modelId) { const model = this.session.modelRuntime.getModel(provider, modelId); if (!model)
82
- throw Object.assign(new Error('Model not found'), { code: 'MODEL_NOT_FOUND' }); await this.session.setModel(model); }
83
- async setThinkingLevel(level) { if (level !== 'off' && !this.session.getAvailableThinkingLevels().includes(level))
84
- throw Object.assign(new Error('Invalid thinking level'), { code: 'INVALID_THINKING_LEVEL' }); this.session.setThinkingLevel(level); }
85
- async setSessionName(name) { const value = name.trim(); if (!value || value.length > 200)
86
- throw Object.assign(new Error('Session name must be 1-200 characters'), { code: 'INVALID_SESSION_NAME' }); this.session.setSessionName(value); }
87
- navigate(entryId) { return this.session.navigateTree(entryId); }
88
- async fork(entryId) { const entry = this.session.sessionManager.getEntry(entryId); if (!entry || entry.type !== 'message' || entry.message?.role !== 'user')
89
- 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) {
90
- sessionFile = this.session.sessionManager.createBranchedSession(entry.parentId);
91
- }
92
- else {
93
- const current = this.session.sessionFile;
94
- if (!current)
95
- throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
96
- sessionFile = SessionManager.create(this.cwd, this.session.sessionManager.getSessionDir(), { parentSession: current }).getSessionFile();
97
- } return { sessionFile, selectedText }; }
194
+ abortBash() {
195
+ this.session.abortBash();
196
+ }
197
+ async getSession() {
198
+ return {
199
+ ...(await this.getSessionInfo()),
200
+ leafId: this.session.sessionManager.getLeafId(),
201
+ entries: this.session.sessionManager.getEntries(),
202
+ tree: this.session.sessionManager.getTree(),
203
+ userMessages: this.session.getUserMessagesForForking()
204
+ };
205
+ }
206
+ async getSessionInfo() {
207
+ return {
208
+ sessionId: this.session.sessionId,
209
+ sessionFile: this.session.sessionFile,
210
+ sessionName: this.session.sessionName,
211
+ stats: this.session.getSessionStats(),
212
+ contextUsage: this.session.getContextUsage()
213
+ };
214
+ }
215
+ compact(instructions) {
216
+ return this.session.compact(instructions);
217
+ }
218
+ async setModel(provider, modelId) {
219
+ const model = this.session.modelRuntime.getModel(provider, modelId);
220
+ if (!model)
221
+ throw Object.assign(new Error('Model not found'), { code: 'MODEL_NOT_FOUND' });
222
+ await this.session.setModel(model);
223
+ }
224
+ async setThinkingLevel(level) {
225
+ if (level !== 'off' && !this.session.getAvailableThinkingLevels().includes(level))
226
+ throw Object.assign(new Error('Invalid thinking level'), { code: 'INVALID_THINKING_LEVEL' });
227
+ this.session.setThinkingLevel(level);
228
+ }
229
+ async setSessionName(name) {
230
+ const value = name.trim();
231
+ if (!value || value.length > 200)
232
+ throw Object.assign(new Error('Session name must be 1-200 characters'), { code: 'INVALID_SESSION_NAME' });
233
+ this.session.setSessionName(value);
234
+ }
235
+ navigate(entryId) {
236
+ return this.session.navigateTree(entryId);
237
+ }
238
+ async fork(entryId) {
239
+ const entry = this.session.sessionManager.getEntry(entryId);
240
+ if (!entry || entry.type !== 'message' || entry.message?.role !== 'user')
241
+ throw Object.assign(new Error('A user message entry is required'), { code: 'SESSION_ENTRY_NOT_FOUND' });
242
+ const content = entry.message.content;
243
+ const selectedText = typeof content === 'string'
244
+ ? content
245
+ : Array.isArray(content)
246
+ ? content
247
+ .filter((part) => part.type === 'text')
248
+ .map((part) => part.text)
249
+ .join('\n')
250
+ : '';
251
+ let sessionFile;
252
+ if (entry.parentId) {
253
+ sessionFile = this.session.sessionManager.createBranchedSession(entry.parentId);
254
+ }
255
+ else {
256
+ const current = this.session.sessionFile;
257
+ if (!current)
258
+ throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
259
+ sessionFile = SessionManager.create(this.cwd, this.session.sessionManager.getSessionDir(), {
260
+ parentSession: current
261
+ }).getSessionFile();
262
+ }
263
+ return { sessionFile, selectedText };
264
+ }
98
265
  async extensionResponse(requestId, value) {
99
266
  if (!this.resolveUiRequest(requestId, value))
100
267
  throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' });
101
268
  }
102
- 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); }; }
269
+ subscribe(listener) {
270
+ const fromSession = (event) => listener(event);
271
+ const unsubscribe = this.session.subscribe(fromSession);
272
+ this.events.on('event', listener);
273
+ return () => {
274
+ unsubscribe();
275
+ this.events.off('event', listener);
276
+ };
277
+ }
103
278
  async dispose() {
104
279
  if (this.disposed)
105
280
  return;
@@ -109,7 +284,16 @@ export class SdkBackend {
109
284
  this.session.dispose();
110
285
  this.disposed = true;
111
286
  }
112
- modelInfo(model) { return { provider: model.provider, id: model.id, name: model.name, reasoning: !!model.reasoning, contextWindow: model.contextWindow, maxTokens: model.maxTokens }; }
287
+ modelInfo(model) {
288
+ return {
289
+ provider: model.provider,
290
+ id: model.id,
291
+ name: model.name,
292
+ reasoning: !!model.reasoning,
293
+ contextWindow: model.contextWindow,
294
+ maxTokens: model.maxTokens
295
+ };
296
+ }
113
297
  resolveUiRequest(requestId, value) {
114
298
  const pending = this.pendingUi.get(requestId);
115
299
  if (!pending)
@@ -132,17 +316,37 @@ export class SdkBackend {
132
316
  }
133
317
  extensionUi() {
134
318
  return {
135
- select: async (title, options, opts) => await this.request('select', { title, options, opts }),
319
+ select: async (title, options, opts) => (await this.request('select', { title, options, opts })),
136
320
  confirm: async (title, message, opts) => Boolean(await this.request('confirm', { title, message, opts })),
137
- input: async (title, placeholder, opts) => await this.request('input', { title, placeholder, opts }),
138
- editor: async (title, prefill) => await this.request('editor', { title, prefill }),
321
+ input: async (title, placeholder, opts) => (await this.request('input', { title, placeholder, opts })),
322
+ editor: async (title, prefill) => (await this.request('editor', { title, prefill })),
139
323
  notify: (message, type) => this.events.emit('event', { type: 'extension_ui_notify', message, notificationType: type ?? 'info' }),
140
324
  setStatus: (key, text) => this.events.emit('event', { type: 'extension_ui_status', key, text }),
141
- setWidget: (key, content, options) => this.events.emit('event', { type: 'extension_ui_widget', key, content: Array.isArray(content) ? content : undefined, options }),
325
+ setWidget: (key, content, options) => this.events.emit('event', {
326
+ type: 'extension_ui_widget',
327
+ key,
328
+ content: Array.isArray(content) ? content : undefined,
329
+ options
330
+ }),
142
331
  setTitle: (title) => this.events.emit('event', { type: 'extension_ui_title', title }),
143
- setWorkingMessage: (message) => this.events.emit('event', { type: 'extension_ui_working_message', message }), setWorkingVisible: () => { }, setWorkingIndicator: () => { }, setHiddenThinkingLabel: () => { },
144
- 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' }); }
332
+ setWorkingMessage: (message) => this.events.emit('event', { type: 'extension_ui_working_message', message }),
333
+ setWorkingVisible: () => { },
334
+ setWorkingIndicator: () => { },
335
+ setHiddenThinkingLabel: () => { },
336
+ onTerminalInput: () => () => { },
337
+ setFooter: () => { },
338
+ setHeader: () => { },
339
+ pasteToEditor: () => { },
340
+ setEditorText: () => { },
341
+ getEditorText: () => '',
342
+ addAutocompleteProvider: () => { },
343
+ setEditorComponent: () => { },
344
+ custom: async () => {
345
+ throw Object.assign(new Error('Custom extension components are unsupported'), {
346
+ code: 'EXTENSION_CUSTOM_UI_UNSUPPORTED'
347
+ });
348
+ }
145
349
  };
146
350
  }
147
351
  }
148
- export const createSdkBackend = (agentId, cwd, sessionFile, profile = 'default', collabBridge) => SdkBackend.create(agentId, cwd, sessionFile, profile, collabBridge);
352
+ export const createSdkBackend = (agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) => SdkBackend.create(agentId, cwd, sessionFile, profile, collabBridge, options);