pi2web 0.2.1 → 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.
- package/README.md +44 -5
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +518 -141
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +190 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +221 -45
- package/dist/server.js +444 -620
- package/dist/session-namer.js +66 -17
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +6 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3098 -743
- package/web/backend-state.js +59 -27
- package/web/collab.js +668 -255
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +4 -1
- package/web/styles.css +14 -0
package/dist/metadata-store.js
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import { chmodSync, mkdirSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { METADATA_DB_FILE } from './paths.js';
|
|
5
|
+
const asTime = (value) => {
|
|
6
|
+
const time = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value ?? '');
|
|
7
|
+
return Number.isFinite(time) ? time : Date.now();
|
|
8
|
+
};
|
|
9
|
+
const parseTemplate = (value) => {
|
|
10
|
+
if (typeof value !== 'string' || !value)
|
|
11
|
+
return;
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(value);
|
|
14
|
+
if (parsed &&
|
|
15
|
+
typeof parsed.id === 'string' &&
|
|
16
|
+
typeof parsed.systemPrompt === 'string' &&
|
|
17
|
+
(parsed.mode === 'replace' || parsed.mode === 'append'))
|
|
18
|
+
return {
|
|
19
|
+
id: parsed.id,
|
|
20
|
+
name: String(parsed.name ?? parsed.id),
|
|
21
|
+
mode: parsed.mode,
|
|
22
|
+
systemPrompt: parsed.systemPrompt
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
return;
|
|
27
|
+
};
|
|
28
|
+
/** SQLite-backed pi2web metadata. Pi conversation JSONL files remain the source of truth. */
|
|
6
29
|
export class MetadataStore {
|
|
7
30
|
dataDir;
|
|
8
31
|
db;
|
|
@@ -12,7 +35,9 @@ export class MetadataStore {
|
|
|
12
35
|
constructor(dataDir) {
|
|
13
36
|
this.dataDir = dataDir;
|
|
14
37
|
}
|
|
15
|
-
get file() {
|
|
38
|
+
get file() {
|
|
39
|
+
return path.join(this.dataDir, METADATA_DB_FILE);
|
|
40
|
+
}
|
|
16
41
|
init() {
|
|
17
42
|
if (this.db)
|
|
18
43
|
return;
|
|
@@ -21,7 +46,7 @@ export class MetadataStore {
|
|
|
21
46
|
chmodSync(this.dataDir, 0o700);
|
|
22
47
|
}
|
|
23
48
|
catch { }
|
|
24
|
-
const db = this.db = new Database(this.file);
|
|
49
|
+
const db = (this.db = new Database(this.file));
|
|
25
50
|
try {
|
|
26
51
|
chmodSync(this.file, 0o600);
|
|
27
52
|
}
|
|
@@ -46,6 +71,7 @@ export class MetadataStore {
|
|
|
46
71
|
cwd TEXT NOT NULL,
|
|
47
72
|
status TEXT NOT NULL,
|
|
48
73
|
profile TEXT NOT NULL DEFAULT 'default',
|
|
74
|
+
template TEXT,
|
|
49
75
|
created_at INTEGER NOT NULL,
|
|
50
76
|
last_active_at INTEGER NOT NULL,
|
|
51
77
|
archived_at INTEGER,
|
|
@@ -82,67 +108,175 @@ export class MetadataStore {
|
|
|
82
108
|
const agentColumns = db.prepare('PRAGMA table_info(agents)').all();
|
|
83
109
|
if (!agentColumns.some(column => column.name === 'profile'))
|
|
84
110
|
db.exec("ALTER TABLE agents ADD COLUMN profile TEXT NOT NULL DEFAULT 'default'");
|
|
111
|
+
// System prompt template snapshot (JSON). Agents created before templates existed use Pi's prompt.
|
|
112
|
+
if (!agentColumns.some(column => column.name === 'template'))
|
|
113
|
+
db.exec('ALTER TABLE agents ADD COLUMN template TEXT');
|
|
85
114
|
// Only stamp the initial version: later migrations (collaboration tables) raise it and must not be reset on restart.
|
|
86
115
|
if (Number(db.pragma('user_version', { simple: true })) < 1)
|
|
87
116
|
db.pragma('user_version = 1');
|
|
88
117
|
this.archivedAgents = new Set(db.prepare('SELECT id FROM agents WHERE archived_at IS NOT NULL').all().map(row => row.id));
|
|
89
118
|
}
|
|
90
|
-
get database() {
|
|
91
|
-
|
|
119
|
+
get database() {
|
|
120
|
+
if (!this.db)
|
|
121
|
+
throw new Error('Metadata store is not initialized');
|
|
122
|
+
return this.db;
|
|
123
|
+
}
|
|
92
124
|
/** Shared handle so collaboration tables live in the same file and the same backup. */
|
|
93
|
-
get connection() {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
125
|
+
get connection() {
|
|
126
|
+
return this.database;
|
|
127
|
+
}
|
|
128
|
+
getSetting(key) {
|
|
129
|
+
const row = this.database.prepare('SELECT value FROM settings WHERE key=?').get(key);
|
|
130
|
+
if (!row)
|
|
131
|
+
return;
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(row.value);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
setSetting(key, value) {
|
|
140
|
+
this.database
|
|
141
|
+
.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at')
|
|
142
|
+
.run(key, JSON.stringify(value), Date.now());
|
|
143
|
+
}
|
|
144
|
+
listWorkspaces() {
|
|
145
|
+
return this.database
|
|
146
|
+
.prepare('SELECT id,label,root_path,created_at FROM workspaces ORDER BY created_at,id')
|
|
147
|
+
.all().map(row => ({
|
|
148
|
+
id: row.id,
|
|
149
|
+
label: row.label,
|
|
150
|
+
rootPath: row.root_path,
|
|
151
|
+
createdAt: new Date(row.created_at).toISOString()
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
saveWorkspace(workspace) {
|
|
155
|
+
this.database
|
|
156
|
+
.prepare(`INSERT INTO workspaces(id,label,root_path,created_at) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET label=excluded.label,root_path=excluded.root_path`)
|
|
157
|
+
.run(workspace.id, workspace.label, workspace.rootPath, asTime(workspace.createdAt));
|
|
158
|
+
}
|
|
159
|
+
listAgents() {
|
|
160
|
+
return this.database
|
|
161
|
+
.prepare('SELECT * FROM agents WHERE archived_at IS NULL ORDER BY last_active_at DESC,id DESC')
|
|
162
|
+
.all().map(row => ({
|
|
163
|
+
agentId: row.id,
|
|
164
|
+
workspaceId: row.workspace_id,
|
|
165
|
+
sessionId: row.session_id,
|
|
166
|
+
sessionFile: row.session_file ?? undefined,
|
|
167
|
+
sessionName: row.session_name ?? undefined,
|
|
168
|
+
cwd: row.cwd,
|
|
169
|
+
status: row.status,
|
|
170
|
+
profile: row.profile === 'collab' ? 'collab' : 'default',
|
|
171
|
+
...(parseTemplate(row.template) ? { template: parseTemplate(row.template) } : {}),
|
|
172
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
173
|
+
lastActiveAt: new Date(row.last_active_at).toISOString()
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
writeAgent(record, archivedAt) {
|
|
177
|
+
this.database
|
|
178
|
+
.prepare(
|
|
179
|
+
// `template` is written on insert only: an Agent's system prompt template never changes.
|
|
180
|
+
`INSERT INTO agents(id,workspace_id,session_id,session_file,session_name,cwd,status,profile,template,created_at,last_active_at,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,session_id=excluded.session_id,session_file=excluded.session_file,session_name=excluded.session_name,cwd=excluded.cwd,status=excluded.status,profile=excluded.profile,last_active_at=excluded.last_active_at,archived_at=excluded.archived_at`)
|
|
181
|
+
.run(record.agentId, record.workspaceId, record.sessionId, record.sessionFile ?? null, record.sessionName ?? null, record.cwd, record.status, record.profile ?? 'default', record.template ? JSON.stringify(record.template) : null, asTime(record.createdAt), asTime(record.lastActiveAt), archivedAt);
|
|
182
|
+
}
|
|
183
|
+
saveAgent(record) {
|
|
184
|
+
if (this.archivedAgents.has(record.agentId))
|
|
185
|
+
return;
|
|
186
|
+
this.pendingAgents.delete(record.agentId);
|
|
187
|
+
this.writeAgent(record, null);
|
|
188
|
+
}
|
|
189
|
+
scheduleAgent(record) {
|
|
190
|
+
if (this.archivedAgents.has(record.agentId))
|
|
191
|
+
return;
|
|
192
|
+
this.pendingAgents.set(record.agentId, { ...record });
|
|
193
|
+
if (!this.flushTimer)
|
|
194
|
+
this.flushTimer = setTimeout(() => this.flush(), 1000);
|
|
195
|
+
}
|
|
196
|
+
archiveAgent(record) {
|
|
197
|
+
this.archivedAgents.add(record.agentId);
|
|
198
|
+
this.pendingAgents.delete(record.agentId);
|
|
199
|
+
this.writeAgent(record, Date.now());
|
|
200
|
+
}
|
|
201
|
+
flush() {
|
|
202
|
+
if (this.flushTimer) {
|
|
203
|
+
clearTimeout(this.flushTimer);
|
|
204
|
+
this.flushTimer = undefined;
|
|
205
|
+
}
|
|
206
|
+
if (!this.pendingAgents.size)
|
|
207
|
+
return;
|
|
208
|
+
const records = [...this.pendingAgents.values()];
|
|
209
|
+
this.pendingAgents.clear();
|
|
210
|
+
this.database.transaction((items) => {
|
|
211
|
+
for (const item of items)
|
|
212
|
+
this.writeAgent(item, null);
|
|
213
|
+
})(records);
|
|
97
214
|
}
|
|
98
|
-
catch {
|
|
99
|
-
return;
|
|
100
|
-
} }
|
|
101
|
-
setSetting(key, value) { this.database.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').run(key, JSON.stringify(value), Date.now()); }
|
|
102
|
-
listWorkspaces() { return this.database.prepare('SELECT id,label,root_path,created_at FROM workspaces ORDER BY created_at,id').all().map(row => ({ id: row.id, label: row.label, rootPath: row.root_path, createdAt: new Date(row.created_at).toISOString() })); }
|
|
103
|
-
saveWorkspace(workspace) { this.database.prepare(`INSERT INTO workspaces(id,label,root_path,created_at) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET label=excluded.label,root_path=excluded.root_path`).run(workspace.id, workspace.label, workspace.rootPath, asTime(workspace.createdAt)); }
|
|
104
|
-
listAgents() { return this.database.prepare('SELECT * FROM agents WHERE archived_at IS NULL ORDER BY last_active_at DESC,id DESC').all().map(row => ({ agentId: row.id, workspaceId: row.workspace_id, sessionId: row.session_id, sessionFile: row.session_file ?? undefined, sessionName: row.session_name ?? undefined, cwd: row.cwd, status: row.status, profile: row.profile === 'collab' ? 'collab' : 'default', createdAt: new Date(row.created_at).toISOString(), lastActiveAt: new Date(row.last_active_at).toISOString() })); }
|
|
105
|
-
writeAgent(record, archivedAt) { this.database.prepare(`INSERT INTO agents(id,workspace_id,session_id,session_file,session_name,cwd,status,profile,created_at,last_active_at,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,session_id=excluded.session_id,session_file=excluded.session_file,session_name=excluded.session_name,cwd=excluded.cwd,status=excluded.status,profile=excluded.profile,last_active_at=excluded.last_active_at,archived_at=excluded.archived_at`).run(record.agentId, record.workspaceId, record.sessionId, record.sessionFile ?? null, record.sessionName ?? null, record.cwd, record.status, record.profile ?? 'default', asTime(record.createdAt), asTime(record.lastActiveAt), archivedAt); }
|
|
106
|
-
saveAgent(record) { if (this.archivedAgents.has(record.agentId))
|
|
107
|
-
return; this.pendingAgents.delete(record.agentId); this.writeAgent(record, null); }
|
|
108
|
-
scheduleAgent(record) { if (this.archivedAgents.has(record.agentId))
|
|
109
|
-
return; this.pendingAgents.set(record.agentId, { ...record }); if (!this.flushTimer)
|
|
110
|
-
this.flushTimer = setTimeout(() => this.flush(), 1000); }
|
|
111
|
-
archiveAgent(record) { this.archivedAgents.add(record.agentId); this.pendingAgents.delete(record.agentId); this.writeAgent(record, Date.now()); }
|
|
112
|
-
flush() { if (this.flushTimer) {
|
|
113
|
-
clearTimeout(this.flushTimer);
|
|
114
|
-
this.flushTimer = undefined;
|
|
115
|
-
} if (!this.pendingAgents.size)
|
|
116
|
-
return; const records = [...this.pendingAgents.values()]; this.pendingAgents.clear(); this.database.transaction((items) => { for (const item of items)
|
|
117
|
-
this.writeAgent(item, null); })(records); }
|
|
118
215
|
syncSessions(workspaceId, cwd, sessions) {
|
|
119
216
|
const db = this.database, upsert = db.prepare(`INSERT INTO sessions(session_id,session_file,workspace_id,cwd,name,parent_session_path,created_at,last_active_at,message_count,first_message,file_size,file_mtime,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,NULL) ON CONFLICT(session_id) DO UPDATE SET session_file=excluded.session_file,workspace_id=excluded.workspace_id,cwd=excluded.cwd,name=excluded.name,parent_session_path=excluded.parent_session_path,last_active_at=excluded.last_active_at,message_count=excluded.message_count,first_message=excluded.first_message,file_size=excluded.file_size,file_mtime=excluded.file_mtime,archived_at=NULL`);
|
|
120
217
|
const remove = db.prepare('DELETE FROM sessions WHERE session_file=?');
|
|
121
|
-
db.transaction(() => {
|
|
122
|
-
paths
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
218
|
+
db.transaction(() => {
|
|
219
|
+
const paths = new Set();
|
|
220
|
+
for (const session of sessions) {
|
|
221
|
+
paths.add(session.path);
|
|
222
|
+
let size = 0, mtime = asTime(session.modified);
|
|
223
|
+
try {
|
|
224
|
+
const stat = statSync(session.path);
|
|
225
|
+
size = stat.size;
|
|
226
|
+
mtime = stat.mtimeMs;
|
|
227
|
+
}
|
|
228
|
+
catch { }
|
|
229
|
+
upsert.run(session.id, session.path, workspaceId, cwd, session.name ?? null, session.parentSessionPath ?? null, asTime(session.created), asTime(session.modified), session.messageCount, session.firstMessage, size, mtime);
|
|
128
230
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
231
|
+
for (const row of db
|
|
232
|
+
.prepare('SELECT session_file FROM sessions WHERE cwd=? AND archived_at IS NULL')
|
|
233
|
+
.all(cwd))
|
|
234
|
+
if (!paths.has(row.session_file))
|
|
235
|
+
remove.run(row.session_file);
|
|
236
|
+
})();
|
|
237
|
+
}
|
|
238
|
+
upsertSessionFromAgent(record, summary) {
|
|
239
|
+
const sessionFile = typeof summary.sessionFile === 'string' ? summary.sessionFile : record.sessionFile;
|
|
240
|
+
if (!sessionFile)
|
|
241
|
+
return;
|
|
242
|
+
let size = 0, mtime = asTime(record.lastActiveAt);
|
|
243
|
+
try {
|
|
244
|
+
const stat = statSync(sessionFile);
|
|
245
|
+
size = stat.size;
|
|
246
|
+
mtime = stat.mtimeMs;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const sessionId = typeof summary.sessionId === 'string' ? summary.sessionId : record.sessionId, name = typeof summary.sessionName === 'string' ? summary.sessionName : record.sessionName, messageCount = Number(summary.stats?.totalMessages) || 0;
|
|
252
|
+
this.database
|
|
253
|
+
.prepare(`INSERT INTO sessions(session_id,session_file,workspace_id,cwd,name,created_at,last_active_at,message_count,first_message,file_size,file_mtime,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL) ON CONFLICT(session_id) DO UPDATE SET session_file=excluded.session_file,workspace_id=excluded.workspace_id,cwd=excluded.cwd,name=excluded.name,last_active_at=excluded.last_active_at,message_count=excluded.message_count,file_size=excluded.file_size,file_mtime=excluded.file_mtime,archived_at=NULL`)
|
|
254
|
+
.run(sessionId, sessionFile, record.workspaceId, record.cwd, name ?? null, asTime(record.createdAt), Math.max(asTime(record.lastActiveAt), mtime), messageCount, '', size, mtime);
|
|
255
|
+
}
|
|
256
|
+
listSessions(cwd, limit, offset = 0) {
|
|
257
|
+
const bounded = limit === undefined ? undefined : Math.min(200, Math.max(1, Math.trunc(limit)));
|
|
258
|
+
const sql = `SELECT session_file AS path,session_id AS id,cwd,name,parent_session_path AS parentSessionPath,created_at,last_active_at,message_count AS messageCount,first_message AS firstMessage FROM sessions WHERE cwd=? AND archived_at IS NULL ORDER BY last_active_at DESC,session_id DESC${bounded === undefined ? '' : ' LIMIT ? OFFSET ?'}`;
|
|
259
|
+
const rows = (bounded === undefined
|
|
260
|
+
? this.database.prepare(sql).all(cwd)
|
|
261
|
+
: this.database.prepare(sql).all(cwd, bounded, Math.max(0, Math.trunc(offset))));
|
|
262
|
+
return rows.map(row => ({
|
|
263
|
+
...row,
|
|
264
|
+
created: new Date(row.created_at).toISOString(),
|
|
265
|
+
modified: new Date(row.last_active_at).toISOString(),
|
|
266
|
+
allMessagesText: ''
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
hasSession(cwd, sessionFile) {
|
|
270
|
+
return !!this.database
|
|
271
|
+
.prepare('SELECT 1 FROM sessions WHERE cwd=? AND session_file=? AND archived_at IS NULL')
|
|
272
|
+
.get(cwd, sessionFile);
|
|
273
|
+
}
|
|
274
|
+
close() {
|
|
275
|
+
if (!this.db)
|
|
276
|
+
return;
|
|
277
|
+
this.flush();
|
|
278
|
+
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
279
|
+
this.db.close();
|
|
280
|
+
this.db = undefined;
|
|
281
|
+
}
|
|
148
282
|
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* pi2web keeps its own state next to Pi's (`~/.pi/agent`): `~/.pi/pi2web` holds the metadata database,
|
|
5
|
+
* pairing-code hashes and the shareable Agent templates. `PI2WEB_DIR` or `--data-dir` override it.
|
|
6
|
+
*/
|
|
7
|
+
export const defaultDataDir = () => process.env.PI2WEB_DIR || path.join(homedir(), '.pi', 'pi2web');
|
|
8
|
+
export const METADATA_DB_FILE = 'pi2web.db';
|
|
9
|
+
/** Shareable Agent templates: one Markdown file per template, see `agent-templates.ts`. */
|
|
10
|
+
export const AGENT_TEMPLATES_DIR = 'agent-templates';
|
package/dist/sdk-backend.js
CHANGED
|
@@ -17,11 +17,35 @@ export class SdkBackend {
|
|
|
17
17
|
this.agentId = agentId;
|
|
18
18
|
this.cwd = cwd;
|
|
19
19
|
}
|
|
20
|
-
static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge) {
|
|
20
|
+
static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) {
|
|
21
21
|
const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
|
|
22
|
+
const { template, piDefaultPrompt } = options;
|
|
22
23
|
// This factory is deliberately inline instead of auto-discovered: normal Pi and normal pi2web agents
|
|
23
24
|
// never load the collaboration tools or their collaboration-specific system guidance.
|
|
24
|
-
const
|
|
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;
|
|
25
49
|
if (resourceLoader)
|
|
26
50
|
await resourceLoader.reload();
|
|
27
51
|
const { session, modelFallbackMessage } = await createAgentSession({ cwd, sessionManager, resourceLoader });
|
|
@@ -35,29 +59,88 @@ export class SdkBackend {
|
|
|
35
59
|
}
|
|
36
60
|
// Slash commands, skills and prompt templates are expanded by prompt() itself (expandPromptTemplates defaults to true),
|
|
37
61
|
// so `/name args` typed in the web composer behaves exactly like the Pi TUI.
|
|
38
|
-
prompt(message) {
|
|
62
|
+
prompt(message) {
|
|
63
|
+
return this.session.prompt(message);
|
|
64
|
+
}
|
|
39
65
|
// Since pi 0.99 steer()/followUp() report whether the message reached the running turn (`handled`)
|
|
40
66
|
// or waits for it (`queued`); the web composer shows that difference instead of guessing.
|
|
41
|
-
async steer(message) {
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
|
44
79
|
/** Drop queued steering and follow-up messages and hand them back so a client can restore them to its composer. */
|
|
45
|
-
async clearQueue() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|
|
49
115
|
async getCapabilities() {
|
|
50
116
|
const current = this.session.model;
|
|
51
117
|
// ModelRuntime.getAvailable() is async since 0.80.8 and may consult provider auth; the snapshot keeps
|
|
52
118
|
// an unauthenticated or offline call from blocking the capabilities endpoint.
|
|
53
|
-
const available = await this.session.modelRuntime
|
|
54
|
-
|
|
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
|
+
};
|
|
55
129
|
}
|
|
56
130
|
/** Slash commands the composer can offer: file-based prompt templates and skills discovered for this cwd. */
|
|
57
131
|
async listCommands() {
|
|
58
132
|
const loader = this.session.resourceLoader;
|
|
59
|
-
const prompts = loader.getPrompts().prompts.map(prompt => ({
|
|
60
|
-
|
|
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
|
+
}));
|
|
61
144
|
return [...prompts, ...skills].sort((a, b) => a.name.localeCompare(b.name));
|
|
62
145
|
}
|
|
63
146
|
/** Run a command inside the Agent session so its output is recorded in the transcript (`!!` keeps it out of the model context). */
|
|
@@ -70,36 +153,100 @@ export class SdkBackend {
|
|
|
70
153
|
return { id, ...result };
|
|
71
154
|
}
|
|
72
155
|
catch (error) {
|
|
73
|
-
this.events.emit('event', {
|
|
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
|
+
});
|
|
74
163
|
throw error;
|
|
75
164
|
}
|
|
76
165
|
}
|
|
77
|
-
abortBash() {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
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
|
+
}
|
|
98
237
|
async extensionResponse(requestId, value) {
|
|
99
238
|
if (!this.resolveUiRequest(requestId, value))
|
|
100
239
|
throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' });
|
|
101
240
|
}
|
|
102
|
-
subscribe(listener) {
|
|
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
|
+
}
|
|
103
250
|
async dispose() {
|
|
104
251
|
if (this.disposed)
|
|
105
252
|
return;
|
|
@@ -109,7 +256,16 @@ export class SdkBackend {
|
|
|
109
256
|
this.session.dispose();
|
|
110
257
|
this.disposed = true;
|
|
111
258
|
}
|
|
112
|
-
modelInfo(model) {
|
|
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
|
+
}
|
|
113
269
|
resolveUiRequest(requestId, value) {
|
|
114
270
|
const pending = this.pendingUi.get(requestId);
|
|
115
271
|
if (!pending)
|
|
@@ -132,17 +288,37 @@ export class SdkBackend {
|
|
|
132
288
|
}
|
|
133
289
|
extensionUi() {
|
|
134
290
|
return {
|
|
135
|
-
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 })),
|
|
136
292
|
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 }),
|
|
293
|
+
input: async (title, placeholder, opts) => (await this.request('input', { title, placeholder, opts })),
|
|
294
|
+
editor: async (title, prefill) => (await this.request('editor', { title, prefill })),
|
|
139
295
|
notify: (message, type) => this.events.emit('event', { type: 'extension_ui_notify', message, notificationType: type ?? 'info' }),
|
|
140
296
|
setStatus: (key, text) => this.events.emit('event', { type: 'extension_ui_status', key, text }),
|
|
141
|
-
setWidget: (key, content, options) => this.events.emit('event', {
|
|
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
|
+
}),
|
|
142
303
|
setTitle: (title) => this.events.emit('event', { type: 'extension_ui_title', title }),
|
|
143
|
-
setWorkingMessage: (message) => this.events.emit('event', { type: 'extension_ui_working_message', message }),
|
|
144
|
-
|
|
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
|
+
}
|
|
145
321
|
};
|
|
146
322
|
}
|
|
147
323
|
}
|
|
148
|
-
export const createSdkBackend = (agentId, cwd, sessionFile, profile = 'default', collabBridge) => SdkBackend.create(agentId, cwd, sessionFile, profile, collabBridge);
|
|
324
|
+
export const createSdkBackend = (agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) => SdkBackend.create(agentId, cwd, sessionFile, profile, collabBridge, options);
|