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.
- package/README.md +209 -18
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +575 -97
- package/dist/auth.js +85 -20
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +221 -0
- package/dist/collab/hub.js +2888 -0
- package/dist/collab/pi-extension.js +494 -0
- package/dist/collab/review-flow.js +459 -0
- package/dist/collab/routes.js +285 -0
- package/dist/collab/schemas.js +281 -0
- package/dist/collab/scoring-flow.js +336 -0
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +918 -0
- package/dist/collab/types.js +137 -0
- package/dist/collab/validate.js +185 -0
- package/dist/drafts.js +50 -0
- 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 +95 -21
- package/dist/main.js +141 -17
- package/dist/mcp.js +132 -0
- package/dist/metadata-store.js +199 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +288 -49
- package/dist/server.js +558 -358
- package/dist/session-namer.js +163 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +196 -40
- 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 +18 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +8 -2
- package/packages/protocol/websocket.schema.json +94 -10
- package/web/activity-view.js +121 -0
- package/web/app.js +3770 -404
- package/web/backend-state.js +269 -0
- package/web/collab.html +95 -0
- package/web/collab.js +1080 -0
- package/web/hid-keyboard.js +453 -0
- package/web/index.html +14 -9
- package/web/styles.css +73 -0
- package/ANDROID_APP_FEATURES.md +0 -256
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { buildTimeline, contentText, pageTimeline } from './timeline.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns one backend's raw events into the detail stream. After any event, if the session's
|
|
4
|
+
* leaf moved, the branch is projected again and the items after the published ones are sent.
|
|
5
|
+
*/
|
|
6
|
+
export class AgentFeed {
|
|
7
|
+
source;
|
|
8
|
+
emit;
|
|
9
|
+
published;
|
|
10
|
+
live = { active: false, text: '', thinking: '' };
|
|
11
|
+
clearLive = false;
|
|
12
|
+
outputs = new Map();
|
|
13
|
+
timer;
|
|
14
|
+
closed = false;
|
|
15
|
+
constructor(source, emit) {
|
|
16
|
+
this.source = source;
|
|
17
|
+
this.emit = emit;
|
|
18
|
+
this.published = this.project().published;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Everything a client needs to start viewing: the newest page and the text streamed so far.
|
|
22
|
+
* Publishes pending changes first, so the next message a subscriber gets is newer than this.
|
|
23
|
+
*/
|
|
24
|
+
snapshot(limit) {
|
|
25
|
+
this.sync();
|
|
26
|
+
const source = this.source(), { active, text, thinking } = this.live;
|
|
27
|
+
return {
|
|
28
|
+
sessionId: source.sessionId,
|
|
29
|
+
page: pageTimeline(buildTimeline(source.entries()), limit),
|
|
30
|
+
live: active || text || thinking ? { text, thinking } : null
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
output(toolCallId) {
|
|
34
|
+
return this.outputs.get(toolCallId);
|
|
35
|
+
}
|
|
36
|
+
handle(event) {
|
|
37
|
+
const e = event;
|
|
38
|
+
switch (event.type) {
|
|
39
|
+
case 'message_start':
|
|
40
|
+
if (e.message?.role === 'assistant' || e.role === 'assistant') {
|
|
41
|
+
// The previous message is persisted by now; publish it before streaming the next.
|
|
42
|
+
this.sync();
|
|
43
|
+
this.live = { active: true, text: '', thinking: '' };
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
case 'message_update': {
|
|
47
|
+
const update = e.assistantMessageEvent;
|
|
48
|
+
if ((update?.type === 'text_delta' || update?.type === 'thinking_delta') &&
|
|
49
|
+
typeof update.delta === 'string') {
|
|
50
|
+
const kind = update.type === 'text_delta' ? 'text' : 'thinking';
|
|
51
|
+
this.live[kind] += update.delta;
|
|
52
|
+
this.emit({ type: 'delta', kind, delta: update.delta });
|
|
53
|
+
}
|
|
54
|
+
return; // Deltas never change the persisted timeline.
|
|
55
|
+
}
|
|
56
|
+
case 'message_end':
|
|
57
|
+
if (this.live.active) {
|
|
58
|
+
this.live.active = false;
|
|
59
|
+
this.clearLive = true;
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case 'tool_execution_update':
|
|
63
|
+
if (!e.parentToolCallId && typeof e.toolCallId === 'string') {
|
|
64
|
+
const result = e.partialResult, output = contentText(result?.content ?? result) || (result ? JSON.stringify(result) : '');
|
|
65
|
+
this.outputs.set(e.toolCallId, output);
|
|
66
|
+
this.emit({ type: 'tool_output', toolCallId: e.toolCallId, output });
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
case 'tool_execution_end':
|
|
70
|
+
this.outputs.delete(String(e.toolCallId));
|
|
71
|
+
break;
|
|
72
|
+
case 'bash_execution_start':
|
|
73
|
+
case 'bash_execution_update':
|
|
74
|
+
case 'bash_execution_end':
|
|
75
|
+
this.emit({ type: 'event', event });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
this.schedule();
|
|
79
|
+
}
|
|
80
|
+
/** Pi persists a message right after notifying listeners of it, so look once the event has finished. */
|
|
81
|
+
schedule() {
|
|
82
|
+
if (this.timer || this.closed)
|
|
83
|
+
return;
|
|
84
|
+
this.timer = setTimeout(() => {
|
|
85
|
+
this.timer = undefined;
|
|
86
|
+
this.sync();
|
|
87
|
+
}, 0);
|
|
88
|
+
}
|
|
89
|
+
/** Publish what changed on the timeline since the last call. Cheap when the leaf did not move. */
|
|
90
|
+
sync() {
|
|
91
|
+
if (this.closed)
|
|
92
|
+
return;
|
|
93
|
+
const source = this.source(), previous = this.published;
|
|
94
|
+
if (source.sessionId === previous.sessionId && source.leafId === previous.leafId && !this.clearLive)
|
|
95
|
+
return;
|
|
96
|
+
const { published, records, entries } = this.project(source);
|
|
97
|
+
this.published = published;
|
|
98
|
+
// An append keeps the old leaf on the branch and every item published so far.
|
|
99
|
+
const appended = source.sessionId === previous.sessionId &&
|
|
100
|
+
(previous.leafId === null || entries.some(entry => entry.id === previous.leafId)) &&
|
|
101
|
+
previous.ids.every((id, i) => published.ids[i] === id);
|
|
102
|
+
const clearLive = this.clearLive;
|
|
103
|
+
this.clearLive = false;
|
|
104
|
+
if (clearLive)
|
|
105
|
+
this.live = { active: false, text: '', thinking: '' };
|
|
106
|
+
if (!appended) {
|
|
107
|
+
this.emit({ type: 'timeline_reset', sessionId: source.sessionId });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const items = records.slice(previous.ids.length).map(r => r.item);
|
|
111
|
+
if (!items.length && !clearLive)
|
|
112
|
+
return;
|
|
113
|
+
this.emit({
|
|
114
|
+
type: 'items',
|
|
115
|
+
sessionId: source.sessionId,
|
|
116
|
+
items,
|
|
117
|
+
...(clearLive ? { clearLive: true } : {})
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
close() {
|
|
121
|
+
this.closed = true;
|
|
122
|
+
if (this.timer)
|
|
123
|
+
clearTimeout(this.timer);
|
|
124
|
+
}
|
|
125
|
+
project(source = this.source()) {
|
|
126
|
+
const entries = source.entries(), records = buildTimeline(entries);
|
|
127
|
+
return {
|
|
128
|
+
published: { sessionId: source.sessionId, leafId: source.leafId, ids: records.map(r => r.item.id) },
|
|
129
|
+
records,
|
|
130
|
+
entries
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { getAgentDir, parseFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
5
|
+
const MAX_PROMPT = 200_000;
|
|
6
|
+
const invalid = (message) => Object.assign(new Error(message), { code: 'INVALID_REQUEST' });
|
|
7
|
+
export const validTemplateId = (id) => typeof id === 'string' && ID.test(id) && !id.endsWith('.') && id !== 'default';
|
|
8
|
+
const asMode = (value) => value === 'replace' || value === 'append' ? value : undefined;
|
|
9
|
+
/** Serialize a template as Markdown with YAML front matter; JSON strings are valid YAML scalars. */
|
|
10
|
+
export const formatTemplateFile = (template) => [
|
|
11
|
+
'---',
|
|
12
|
+
`name: ${JSON.stringify(template.name)}`,
|
|
13
|
+
...(template.description ? [`description: ${JSON.stringify(template.description)}`] : []),
|
|
14
|
+
`mode: ${template.mode}`,
|
|
15
|
+
'---',
|
|
16
|
+
'',
|
|
17
|
+
template.systemPrompt.trim(),
|
|
18
|
+
''
|
|
19
|
+
].join('\n');
|
|
20
|
+
/**
|
|
21
|
+
* Agent templates are plain files under `<data-dir>/agent-templates/<id>.md` so they can be shared by copying:
|
|
22
|
+
*
|
|
23
|
+
* ```markdown
|
|
24
|
+
* ---
|
|
25
|
+
* name: Code reviewer
|
|
26
|
+
* description: Reviews diffs, never edits files
|
|
27
|
+
* mode: replace # or append
|
|
28
|
+
* ---
|
|
29
|
+
* You are a meticulous code reviewer...
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Files are re-read on every call, so templates dropped into the directory by hand show up immediately.
|
|
33
|
+
*/
|
|
34
|
+
export class AgentTemplateStore {
|
|
35
|
+
dir;
|
|
36
|
+
constructor(dir) {
|
|
37
|
+
this.dir = dir;
|
|
38
|
+
}
|
|
39
|
+
fileFor(id) {
|
|
40
|
+
if (!validTemplateId(id))
|
|
41
|
+
throw invalid('Template id must be 1-64 letters, digits, dot, underscore or hyphen');
|
|
42
|
+
return path.join(this.dir, `${id}.md`);
|
|
43
|
+
}
|
|
44
|
+
read(id, file) {
|
|
45
|
+
const raw = readFileSync(file, 'utf8');
|
|
46
|
+
let frontmatter = {}, body = raw;
|
|
47
|
+
try {
|
|
48
|
+
({ frontmatter, body } = parseFrontmatter(raw));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw Object.assign(new Error(`Invalid front matter in ${file}: ${error.message}`), {
|
|
52
|
+
code: 'INVALID_TEMPLATE'
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const name = typeof frontmatter.name === 'string' && frontmatter.name.trim() ? frontmatter.name.trim() : id;
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
name,
|
|
59
|
+
description: typeof frontmatter.description === 'string' ? frontmatter.description.trim() : '',
|
|
60
|
+
mode: asMode(frontmatter.mode) ?? 'append',
|
|
61
|
+
systemPrompt: body.trim(),
|
|
62
|
+
file,
|
|
63
|
+
updatedAt: statSync(file).mtime.toISOString()
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
list() {
|
|
67
|
+
if (!existsSync(this.dir))
|
|
68
|
+
return { templates: [], errors: [] };
|
|
69
|
+
const templates = [], errors = [];
|
|
70
|
+
for (const entry of readdirSync(this.dir, { withFileTypes: true })) {
|
|
71
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
72
|
+
continue;
|
|
73
|
+
const id = entry.name.slice(0, -3);
|
|
74
|
+
if (!validTemplateId(id)) {
|
|
75
|
+
errors.push(`Ignored ${entry.name}: invalid template id`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
templates.push(this.read(id, path.join(this.dir, entry.name)));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
errors.push(error.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
templates.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
86
|
+
return { templates, errors };
|
|
87
|
+
}
|
|
88
|
+
get(id) {
|
|
89
|
+
const file = this.fileFor(id);
|
|
90
|
+
if (!existsSync(file))
|
|
91
|
+
throw Object.assign(new Error(`Agent template not found: ${id}`), { code: 'TEMPLATE_NOT_FOUND' });
|
|
92
|
+
return this.read(id, file);
|
|
93
|
+
}
|
|
94
|
+
save(id, input) {
|
|
95
|
+
const file = this.fileFor(id);
|
|
96
|
+
const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim() : id;
|
|
97
|
+
if (name.length > 100 || /[\r\n]/.test(name))
|
|
98
|
+
throw invalid('name must be a single line of at most 100 characters');
|
|
99
|
+
const description = typeof input.description === 'string' ? input.description.trim() : '';
|
|
100
|
+
if (description.length > 500 || /[\r\n]/.test(description))
|
|
101
|
+
throw invalid('description must be a single line of at most 500 characters');
|
|
102
|
+
const mode = asMode(input.mode);
|
|
103
|
+
if (!mode)
|
|
104
|
+
throw invalid('mode must be replace or append');
|
|
105
|
+
if (typeof input.systemPrompt !== 'string' || !input.systemPrompt.trim())
|
|
106
|
+
throw invalid('systemPrompt is required');
|
|
107
|
+
if (input.systemPrompt.length > MAX_PROMPT)
|
|
108
|
+
throw invalid('systemPrompt is too long');
|
|
109
|
+
mkdirSync(this.dir, { recursive: true });
|
|
110
|
+
writeFileSync(file, formatTemplateFile({ name, description, mode, systemPrompt: input.systemPrompt }), 'utf8');
|
|
111
|
+
return this.read(id, file);
|
|
112
|
+
}
|
|
113
|
+
remove(id) {
|
|
114
|
+
const file = this.fileFor(id);
|
|
115
|
+
if (!existsSync(file))
|
|
116
|
+
throw Object.assign(new Error(`Agent template not found: ${id}`), { code: 'TEMPLATE_NOT_FOUND' });
|
|
117
|
+
rmSync(file);
|
|
118
|
+
}
|
|
119
|
+
snapshot(id) {
|
|
120
|
+
const { name, mode, systemPrompt } = this.get(id);
|
|
121
|
+
return { id, name, mode, systemPrompt };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const PI_PROMPT_FILES = { system: 'SYSTEM.md', append: 'APPEND_SYSTEM.md' };
|
|
125
|
+
export const piPromptFile = (kind, agentDir = getAgentDir()) => path.join(agentDir, PI_PROMPT_FILES[kind]);
|
|
126
|
+
export function readPiPromptFiles(agentDir = getAgentDir()) {
|
|
127
|
+
const read = (kind) => {
|
|
128
|
+
const file = piPromptFile(kind, agentDir), exists = existsSync(file);
|
|
129
|
+
return { path: file, exists, content: exists ? readFileSync(file, 'utf8') : '' };
|
|
130
|
+
};
|
|
131
|
+
return { agentDir, system: read('system'), append: read('append') };
|
|
132
|
+
}
|
|
133
|
+
/** Write (or, for an empty value, delete) a global Pi prompt file. Deleting SYSTEM.md restores Pi's built-in prompt. */
|
|
134
|
+
export function writePiPromptFile(kind, content, agentDir = getAgentDir()) {
|
|
135
|
+
if (content.length > MAX_PROMPT)
|
|
136
|
+
throw invalid('content is too long');
|
|
137
|
+
const file = piPromptFile(kind, agentDir);
|
|
138
|
+
if (!content.trim())
|
|
139
|
+
rmSync(file, { force: true });
|
|
140
|
+
else {
|
|
141
|
+
mkdirSync(agentDir, { recursive: true });
|
|
142
|
+
writeFileSync(file, content, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
}
|