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,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
- const asTime = (value) => { const time = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value ?? ''); return Number.isFinite(time) ? time : Date.now(); };
5
- /** SQLite-backed Remote Pi metadata. Pi conversation JSONL files remain the source of truth. */
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() { return path.join(this.dataDir, 'remote-pi.db'); }
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,188 @@ 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() { if (!this.db)
91
- throw new Error('Metadata store is not initialized'); return this.db; }
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() { return this.database; }
94
- getSetting(key) { const row = this.database.prepare('SELECT value FROM settings WHERE key=?').get(key); if (!row)
95
- return; try {
96
- return JSON.parse(row.value);
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
+ // Ordinary state/index writes must not overwrite the conversation's template snapshot.
180
+ // Only commitAgentTemplate may change it after creation.
181
+ `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`)
182
+ .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);
183
+ }
184
+ saveAgent(record) {
185
+ if (this.archivedAgents.has(record.agentId))
186
+ return;
187
+ this.pendingAgents.delete(record.agentId);
188
+ this.writeAgent(record, null);
189
+ }
190
+ /** Commit the validated pre-conversation choice and its session identity together. */
191
+ commitAgentTemplate(record) {
192
+ this.database.transaction(() => {
193
+ const updated = this.database
194
+ .prepare('UPDATE agents SET template=? WHERE id=? AND archived_at IS NULL')
195
+ .run(record.template ? JSON.stringify(record.template) : null, record.agentId);
196
+ if (!updated.changes)
197
+ throw Object.assign(new Error('Agent not found'), { code: 'AGENT_NOT_FOUND' });
198
+ this.writeAgent(record, null);
199
+ })();
200
+ this.pendingAgents.delete(record.agentId);
201
+ }
202
+ scheduleAgent(record) {
203
+ if (this.archivedAgents.has(record.agentId))
204
+ return;
205
+ this.pendingAgents.set(record.agentId, { ...record });
206
+ if (!this.flushTimer)
207
+ this.flushTimer = setTimeout(() => this.flush(), 1000);
208
+ }
209
+ archiveAgent(record) {
210
+ this.archivedAgents.add(record.agentId);
211
+ this.pendingAgents.delete(record.agentId);
212
+ this.writeAgent(record, Date.now());
213
+ }
214
+ flush() {
215
+ if (this.flushTimer) {
216
+ clearTimeout(this.flushTimer);
217
+ this.flushTimer = undefined;
218
+ }
219
+ if (!this.pendingAgents.size)
220
+ return;
221
+ const records = [...this.pendingAgents.values()];
222
+ this.pendingAgents.clear();
223
+ this.database.transaction((items) => {
224
+ for (const item of items)
225
+ this.writeAgent(item, null);
226
+ })(records);
97
227
  }
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
228
  syncSessions(workspaceId, cwd, sessions) {
119
229
  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
230
  const remove = db.prepare('DELETE FROM sessions WHERE session_file=?');
121
- db.transaction(() => { const paths = new Set(); for (const session of sessions) {
122
- paths.add(session.path);
123
- let size = 0, mtime = asTime(session.modified);
124
- try {
125
- const stat = statSync(session.path);
126
- size = stat.size;
127
- mtime = stat.mtimeMs;
231
+ db.transaction(() => {
232
+ const paths = new Set();
233
+ for (const session of sessions) {
234
+ paths.add(session.path);
235
+ let size = 0, mtime = asTime(session.modified);
236
+ try {
237
+ const stat = statSync(session.path);
238
+ size = stat.size;
239
+ mtime = stat.mtimeMs;
240
+ }
241
+ catch { }
242
+ 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
243
  }
129
- catch { }
130
- 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);
131
- } for (const row of db.prepare('SELECT session_file FROM sessions WHERE cwd=? AND archived_at IS NULL').all(cwd))
132
- if (!paths.has(row.session_file))
133
- remove.run(row.session_file); })();
134
- }
135
- upsertSessionFromAgent(record, summary) { const sessionFile = typeof summary.sessionFile === 'string' ? summary.sessionFile : record.sessionFile; if (!sessionFile)
136
- return; let size = 0, mtime = asTime(record.lastActiveAt); try {
137
- const stat = statSync(sessionFile);
138
- size = stat.size;
139
- mtime = stat.mtimeMs;
140
- }
141
- catch {
142
- return;
143
- } 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; this.database.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`).run(sessionId, sessionFile, record.workspaceId, record.cwd, name ?? null, asTime(record.createdAt), Math.max(asTime(record.lastActiveAt), mtime), messageCount, '', size, mtime); }
144
- listSessions(cwd, limit, offset = 0) { const bounded = limit === undefined ? undefined : Math.min(200, Math.max(1, Math.trunc(limit))); 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 ?'}`; const rows = (bounded === undefined ? this.database.prepare(sql).all(cwd) : this.database.prepare(sql).all(cwd, bounded, Math.max(0, Math.trunc(offset)))); return rows.map(row => ({ ...row, created: new Date(row.created_at).toISOString(), modified: new Date(row.last_active_at).toISOString(), allMessagesText: '' })); }
145
- hasSession(cwd, sessionFile) { return !!this.database.prepare('SELECT 1 FROM sessions WHERE cwd=? AND session_file=? AND archived_at IS NULL').get(cwd, sessionFile); }
146
- close() { if (!this.db)
147
- return; this.flush(); this.db.pragma('wal_checkpoint(TRUNCATE)'); this.db.close(); this.db = undefined; }
244
+ for (const row of db
245
+ .prepare('SELECT session_file FROM sessions WHERE cwd=? AND archived_at IS NULL')
246
+ .all(cwd))
247
+ if (!paths.has(row.session_file))
248
+ remove.run(row.session_file);
249
+ })();
250
+ }
251
+ upsertSessionFromAgent(record, summary) {
252
+ const sessionFile = typeof summary.sessionFile === 'string' ? summary.sessionFile : record.sessionFile;
253
+ if (!sessionFile)
254
+ return;
255
+ let size = 0, mtime = asTime(record.lastActiveAt);
256
+ try {
257
+ const stat = statSync(sessionFile);
258
+ size = stat.size;
259
+ mtime = stat.mtimeMs;
260
+ }
261
+ catch {
262
+ return;
263
+ }
264
+ 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;
265
+ this.database
266
+ .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`)
267
+ .run(sessionId, sessionFile, record.workspaceId, record.cwd, name ?? null, asTime(record.createdAt), Math.max(asTime(record.lastActiveAt), mtime), messageCount, '', size, mtime);
268
+ }
269
+ listSessions(cwd, limit, offset = 0) {
270
+ const bounded = limit === undefined ? undefined : Math.min(200, Math.max(1, Math.trunc(limit)));
271
+ 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 ?'}`;
272
+ const rows = (bounded === undefined
273
+ ? this.database.prepare(sql).all(cwd)
274
+ : this.database.prepare(sql).all(cwd, bounded, Math.max(0, Math.trunc(offset))));
275
+ return rows.map(row => ({
276
+ ...row,
277
+ created: new Date(row.created_at).toISOString(),
278
+ modified: new Date(row.last_active_at).toISOString(),
279
+ allMessagesText: ''
280
+ }));
281
+ }
282
+ hasSession(cwd, sessionFile) {
283
+ return !!this.database
284
+ .prepare('SELECT 1 FROM sessions WHERE cwd=? AND session_file=? AND archived_at IS NULL')
285
+ .get(cwd, sessionFile);
286
+ }
287
+ close() {
288
+ if (!this.db)
289
+ return;
290
+ this.flush();
291
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
292
+ this.db.close();
293
+ this.db = undefined;
294
+ }
148
295
  }
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';