@parley-im/parley 0.1.2 → 0.2.1
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 +19 -17
- package/bin/parley +7 -0
- package/dist/access.js +57 -0
- package/dist/account.js +5 -1
- package/dist/agents/claude.js +20 -11
- package/dist/agents/codex.js +10 -5
- package/dist/agents/process.js +35 -4
- package/dist/api.js +32 -10
- package/dist/autostart.js +86 -0
- package/dist/charter.js +6 -5
- package/dist/cli.js +62 -16
- package/dist/config.js +49 -9
- package/dist/control.js +16 -10
- package/dist/daemon.js +34 -10
- package/dist/db.js +111 -27
- package/dist/mcp.js +20 -20
- package/dist/models.js +8 -2
- package/dist/relay.js +35 -10
- package/dist/runner.js +9 -7
- package/dist/seal.js +7 -5
- package/dist/server.js +43 -17
- package/dist/state.js +7 -4
- package/dist/transcript.js +10 -6
- package/dist/version.js +15 -0
- package/package.json +2 -2
- package/web/app.css +29 -13
- package/web/chat.html +6 -6
- package/web/chat.js +198 -62
- package/web/dashboard.js +41 -0
- package/web/ended.html +1 -1
- package/web/missing.html +1 -1
- package/web/panel.html +4 -4
- package/web/panel.js +6 -6
package/dist/db.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import { paths, ensureHome } from "./config.js";
|
|
6
6
|
import { newKey } from "./seal.js";
|
|
@@ -11,7 +11,7 @@ create table if not exists sessions (
|
|
|
11
11
|
token text not null unique,
|
|
12
12
|
project_dir text not null,
|
|
13
13
|
project_name text not null,
|
|
14
|
-
|
|
14
|
+
guest text not null,
|
|
15
15
|
brief text not null,
|
|
16
16
|
greeting text not null,
|
|
17
17
|
agent text not null,
|
|
@@ -42,14 +42,27 @@ create table if not exists messages (
|
|
|
42
42
|
role text not null,
|
|
43
43
|
body text not null,
|
|
44
44
|
images text not null default '[]',
|
|
45
|
-
-- For
|
|
45
|
+
-- For guest messages: the turn that answered it (null while waiting).
|
|
46
46
|
-- For agent and error messages: the turn that produced it.
|
|
47
47
|
turn_id integer references turns(id),
|
|
48
|
+
tries integer not null default 0,
|
|
49
|
+
client_id text,
|
|
48
50
|
created_at text not null
|
|
49
51
|
);
|
|
50
52
|
create index if not exists messages_session on messages(session_id, id);
|
|
51
53
|
create index if not exists turns_session on turns(session_id, id);
|
|
54
|
+
create unique index if not exists messages_client on messages(session_id, client_id) where client_id is not null;
|
|
52
55
|
`;
|
|
56
|
+
// Changes to a project's database since parley 0.1.2, in order. New
|
|
57
|
+
// databases start with the schema above, which already has them all; a
|
|
58
|
+
// project's .parley folder can outlive any version of parley, so these stay.
|
|
59
|
+
const UPGRADES = [
|
|
60
|
+
`alter table messages add column tries integer not null default 0;
|
|
61
|
+
alter table messages add column client_id text;
|
|
62
|
+
create unique index messages_client on messages(session_id, client_id) where client_id is not null;`,
|
|
63
|
+
`alter table sessions rename column tester to guest;
|
|
64
|
+
update messages set role = 'guest' where role = 'tester';`,
|
|
65
|
+
];
|
|
53
66
|
const now = () => new Date().toISOString();
|
|
54
67
|
// A project's own parley folder: its sessions' database, and a folder per
|
|
55
68
|
// session with the screenshots and a readable transcript. Kept out of git,
|
|
@@ -66,15 +79,38 @@ export class Store {
|
|
|
66
79
|
ensureHome();
|
|
67
80
|
this.refresh();
|
|
68
81
|
}
|
|
69
|
-
// Opens any project another parley process has added since
|
|
82
|
+
// Opens any project another parley process has added since, and lets go
|
|
83
|
+
// of any whose .parley folder isn't there anymore (moved or deleted).
|
|
70
84
|
refresh() {
|
|
71
|
-
for (const dir of
|
|
72
|
-
if (
|
|
73
|
-
|
|
85
|
+
for (const [dir, db] of this.projects) {
|
|
86
|
+
if (existsSync(join(parleyDir(dir), "parley.db")))
|
|
87
|
+
continue;
|
|
88
|
+
db.close();
|
|
89
|
+
this.projects.delete(dir);
|
|
90
|
+
for (const [id, owner] of this.where)
|
|
91
|
+
if (owner === db)
|
|
92
|
+
this.where.delete(id);
|
|
93
|
+
}
|
|
94
|
+
const listed = readProjects();
|
|
95
|
+
if (!listed)
|
|
96
|
+
return;
|
|
97
|
+
for (const dir of listed) {
|
|
98
|
+
if (this.projects.has(dir) || !existsSync(join(parleyDir(dir), "parley.db")))
|
|
99
|
+
continue;
|
|
100
|
+
const db = new ProjectDb(dir);
|
|
101
|
+
// A copied project brings copies of its sessions, keys and all. The
|
|
102
|
+
// first one listed keeps them; the copy is left alone.
|
|
103
|
+
const taken = db.ids().some((id) => [...this.projects.values()].some((other) => other.session(id)));
|
|
104
|
+
if (taken) {
|
|
105
|
+
console.error(`parley: ${dir} holds copies of another project's sessions; leaving it out.`);
|
|
106
|
+
db.close();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
this.projects.set(dir, db);
|
|
74
110
|
}
|
|
75
111
|
}
|
|
76
112
|
project(dir) {
|
|
77
|
-
dir =
|
|
113
|
+
dir = realpathSync(dir);
|
|
78
114
|
let db = this.projects.get(dir);
|
|
79
115
|
if (!db) {
|
|
80
116
|
mkdirSync(parleyDir(dir), { recursive: true });
|
|
@@ -83,8 +119,10 @@ export class Store {
|
|
|
83
119
|
db = new ProjectDb(dir);
|
|
84
120
|
this.projects.set(dir, db);
|
|
85
121
|
const listed = readProjects();
|
|
122
|
+
if (!listed)
|
|
123
|
+
throw new Error(`Can't read ${paths.projects}. Fix or delete it, then try again.`);
|
|
86
124
|
if (!listed.includes(dir))
|
|
87
|
-
|
|
125
|
+
writeProjects([...listed, dir]);
|
|
88
126
|
}
|
|
89
127
|
return db;
|
|
90
128
|
}
|
|
@@ -136,7 +174,8 @@ export class Store {
|
|
|
136
174
|
}
|
|
137
175
|
sessions(filter = {}) {
|
|
138
176
|
this.refresh();
|
|
139
|
-
const
|
|
177
|
+
const dir = filter.projectDir && (existsSync(filter.projectDir) ? realpathSync(filter.projectDir) : resolve(filter.projectDir));
|
|
178
|
+
const dbs = dir ? [this.projects.get(dir)].filter((db) => !!db) : [...this.projects.values()];
|
|
140
179
|
return dbs.flatMap((db) => db.sessions(filter.status)).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
141
180
|
}
|
|
142
181
|
// Erases the session: transcript, turns, screenshots and its folder.
|
|
@@ -167,8 +206,8 @@ export class Store {
|
|
|
167
206
|
setAgentSessionId(id, agentSessionId) {
|
|
168
207
|
this.on(id, (db) => db.setAgentSessionId(id, agentSessionId), undefined);
|
|
169
208
|
}
|
|
170
|
-
addMessage(sessionId, role, body, images, turnId) {
|
|
171
|
-
const message = this.of(sessionId).addMessage(sessionId, role, body, images, turnId);
|
|
209
|
+
addMessage(sessionId, role, body, images, turnId, clientId = null) {
|
|
210
|
+
const message = this.of(sessionId).addMessage(sessionId, role, body, images, turnId, clientId);
|
|
172
211
|
this.write(sessionId);
|
|
173
212
|
return message;
|
|
174
213
|
}
|
|
@@ -199,6 +238,12 @@ export class Store {
|
|
|
199
238
|
releaseTurn(sessionId, turnId) {
|
|
200
239
|
this.on(sessionId, (db) => db.releaseTurn(turnId), undefined);
|
|
201
240
|
}
|
|
241
|
+
failTurn(sessionId, turnId) {
|
|
242
|
+
return this.on(sessionId, (db) => db.failTurn(turnId), { retrying: 0, setAside: 0 });
|
|
243
|
+
}
|
|
244
|
+
messageByClient(sessionId, clientId) {
|
|
245
|
+
return this.on(sessionId, (db) => db.messageByClient(sessionId, clientId), undefined);
|
|
246
|
+
}
|
|
202
247
|
// Turns that never finished (the server stopped mid-reply), everywhere.
|
|
203
248
|
recoverInterrupted() {
|
|
204
249
|
this.refresh();
|
|
@@ -208,23 +253,35 @@ export class Store {
|
|
|
208
253
|
return this.of(sessionId).stats(sessionId);
|
|
209
254
|
}
|
|
210
255
|
// Keeps <session>/transcript.md in step, for you and your agent to read.
|
|
256
|
+
// Not if the project has moved out from under us: no folders get made at
|
|
257
|
+
// the old path.
|
|
211
258
|
write(sessionId) {
|
|
212
259
|
const session = this.session(sessionId);
|
|
213
|
-
if (!session)
|
|
260
|
+
if (!session || !existsSync(parleyDir(session.project_dir)))
|
|
214
261
|
return;
|
|
215
262
|
mkdirSync(sessionDir(session), { recursive: true });
|
|
216
263
|
writeFileSync(join(sessionDir(session), "transcript.md"), record(this, session));
|
|
217
264
|
}
|
|
218
265
|
}
|
|
266
|
+
// The listed projects; an empty list if there's no file yet, and null if
|
|
267
|
+
// it can't be read (so nothing overwrites it with a list of one).
|
|
219
268
|
function readProjects() {
|
|
269
|
+
if (!existsSync(paths.projects))
|
|
270
|
+
return [];
|
|
220
271
|
try {
|
|
221
272
|
const list = JSON.parse(readFileSync(paths.projects, "utf8"));
|
|
222
|
-
return Array.isArray(list) ? list.filter((dir) => typeof dir === "string") :
|
|
273
|
+
return Array.isArray(list) ? list.filter((dir) => typeof dir === "string") : null;
|
|
223
274
|
}
|
|
224
275
|
catch {
|
|
225
|
-
return
|
|
276
|
+
return null;
|
|
226
277
|
}
|
|
227
278
|
}
|
|
279
|
+
// Written whole and then swapped in, so a crash can't leave half a file.
|
|
280
|
+
function writeProjects(list) {
|
|
281
|
+
const temp = `${paths.projects}.${process.pid}.tmp`;
|
|
282
|
+
writeFileSync(temp, JSON.stringify(list, null, 2) + "\n");
|
|
283
|
+
renameSync(temp, paths.projects);
|
|
284
|
+
}
|
|
228
285
|
// One project's sessions, in <project>/.parley/parley.db.
|
|
229
286
|
class ProjectDb {
|
|
230
287
|
db;
|
|
@@ -233,20 +290,34 @@ class ProjectDb {
|
|
|
233
290
|
this.dir = dir;
|
|
234
291
|
this.db = new DatabaseSync(join(parleyDir(dir), "parley.db"));
|
|
235
292
|
this.db.exec("pragma journal_mode = wal; pragma busy_timeout = 5000; pragma foreign_keys = on;");
|
|
236
|
-
this.db.
|
|
293
|
+
const fresh = !this.db.prepare("select 1 from sqlite_master where name = 'sessions'").get();
|
|
294
|
+
if (fresh)
|
|
295
|
+
this.db.exec(`begin; ${schema} pragma user_version = ${UPGRADES.length}; commit;`);
|
|
296
|
+
const at = Number(this.db.prepare("pragma user_version").get().user_version);
|
|
297
|
+
for (let v = at; v < UPGRADES.length; v++)
|
|
298
|
+
this.db.exec(`begin; ${UPGRADES[v]} pragma user_version = ${v + 1}; commit;`);
|
|
237
299
|
}
|
|
238
300
|
// A project that moved still has its sessions; they're wherever it is now.
|
|
239
301
|
row = (row) => row && { ...row, project_dir: this.dir };
|
|
302
|
+
ids() {
|
|
303
|
+
return this.db.prepare("select id from sessions").all().map((r) => String(r.id));
|
|
304
|
+
}
|
|
305
|
+
close() {
|
|
306
|
+
try {
|
|
307
|
+
this.db.close();
|
|
308
|
+
}
|
|
309
|
+
catch { }
|
|
310
|
+
}
|
|
240
311
|
createSession(id, input) {
|
|
241
312
|
const token = randomBytes(18).toString("base64url");
|
|
242
313
|
const projectName = basename(this.dir);
|
|
243
314
|
const greeting = input.greeting?.trim() ||
|
|
244
|
-
`Hi ${input.
|
|
315
|
+
`Hi ${input.guest}! Thanks for trying ${projectName}. Tell me anything: what you tried, what tripped you up, what you liked. Long rambling brain dumps are perfect.`;
|
|
245
316
|
const t = now();
|
|
246
317
|
this.db
|
|
247
|
-
.prepare(`insert into sessions (id, token, project_dir, project_name,
|
|
318
|
+
.prepare(`insert into sessions (id, token, project_dir, project_name, guest, brief, greeting, agent, model, reply_limit, e2e_key, status, created_at, updated_at)
|
|
248
319
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`)
|
|
249
|
-
.run(id, token, this.dir, projectName, input.
|
|
320
|
+
.run(id, token, this.dir, projectName, input.guest, input.brief, greeting, input.agent, input.model ?? null, input.replyLimit, newKey(), t, t);
|
|
250
321
|
}
|
|
251
322
|
session(id) {
|
|
252
323
|
return this.row(this.db.prepare("select * from sessions where id = ?").get(id));
|
|
@@ -290,10 +361,10 @@ class ProjectDb {
|
|
|
290
361
|
touch(id) {
|
|
291
362
|
this.db.prepare("update sessions set updated_at = ? where id = ?").run(now(), id);
|
|
292
363
|
}
|
|
293
|
-
addMessage(sessionId, role, body, images, turnId) {
|
|
364
|
+
addMessage(sessionId, role, body, images, turnId, clientId = null) {
|
|
294
365
|
const result = this.db
|
|
295
|
-
.prepare("insert into messages (session_id, role, body, images, turn_id, created_at) values (?, ?, ?, ?, ?, ?)")
|
|
296
|
-
.run(sessionId, role, body, JSON.stringify(images), turnId, now());
|
|
366
|
+
.prepare("insert into messages (session_id, role, body, images, turn_id, client_id, created_at) values (?, ?, ?, ?, ?, ?, ?)")
|
|
367
|
+
.run(sessionId, role, body, JSON.stringify(images), turnId, clientId, now());
|
|
297
368
|
this.touch(sessionId);
|
|
298
369
|
return this.message(Number(result.lastInsertRowid));
|
|
299
370
|
}
|
|
@@ -314,7 +385,7 @@ class ProjectDb {
|
|
|
314
385
|
}
|
|
315
386
|
pending(sessionId) {
|
|
316
387
|
return this.db
|
|
317
|
-
.prepare("select * from messages where session_id = ? and role = '
|
|
388
|
+
.prepare("select * from messages where session_id = ? and role = 'guest' and turn_id is null order by id")
|
|
318
389
|
.all(sessionId).map(parseMessage);
|
|
319
390
|
}
|
|
320
391
|
startTurn(session, messageIds) {
|
|
@@ -346,7 +417,20 @@ class ProjectDb {
|
|
|
346
417
|
.run(status, usage ? JSON.stringify(usage) : null, error, now(), turnId);
|
|
347
418
|
}
|
|
348
419
|
releaseTurn(turnId) {
|
|
349
|
-
this.db.prepare("update messages set turn_id = null where turn_id = ? and role = '
|
|
420
|
+
this.db.prepare("update messages set turn_id = null where turn_id = ? and role = 'guest'").run(turnId);
|
|
421
|
+
}
|
|
422
|
+
// A reply failed: its messages go back in the queue to ride along with the
|
|
423
|
+
// next one, unless they've already failed once, in which case they're set
|
|
424
|
+
// aside (still saved, never retried) so one bad message can't sink the rest.
|
|
425
|
+
failTurn(turnId) {
|
|
426
|
+
this.db.prepare("update messages set tries = tries + 1 where turn_id = ? and role = 'guest'").run(turnId);
|
|
427
|
+
const back = this.db.prepare("update messages set turn_id = null where turn_id = ? and role = 'guest' and tries < 2").run(turnId);
|
|
428
|
+
const aside = this.db.prepare("select count(*) as n from messages where turn_id = ? and role = 'guest'").get(turnId);
|
|
429
|
+
return { retrying: Number(back.changes), setAside: Number(aside.n) };
|
|
430
|
+
}
|
|
431
|
+
messageByClient(sessionId, clientId) {
|
|
432
|
+
const row = this.db.prepare("select * from messages where session_id = ? and client_id = ?").get(sessionId, clientId);
|
|
433
|
+
return row && parseMessage(row);
|
|
350
434
|
}
|
|
351
435
|
// A turn that never finished (server stopped mid-reply) gives its messages
|
|
352
436
|
// back to the queue so they are answered when the server comes back.
|
|
@@ -363,11 +447,11 @@ class ProjectDb {
|
|
|
363
447
|
stats(sessionId) {
|
|
364
448
|
const row = this.db
|
|
365
449
|
.prepare(`select
|
|
366
|
-
coalesce(sum(m.role = '
|
|
450
|
+
coalesce(sum(m.role = 'guest'), 0) as guest_messages,
|
|
367
451
|
coalesce(sum(m.role = 'agent'), 0) as agent_messages,
|
|
368
|
-
coalesce(sum(m.role = '
|
|
452
|
+
coalesce(sum(m.role = 'guest' and m.id > s.read_through), 0) as unread,
|
|
369
453
|
max(m.created_at) as last_message_at,
|
|
370
|
-
max(case when m.role = '
|
|
454
|
+
max(case when m.role = 'guest' then m.created_at end) as last_guest_at
|
|
371
455
|
from sessions s left join messages m on m.session_id = s.id
|
|
372
456
|
where s.id = ?`)
|
|
373
457
|
.get(sessionId);
|
package/dist/mcp.js
CHANGED
|
@@ -8,9 +8,9 @@ import { linkFor, dashboardUrl, isServerUp } from "./state.js";
|
|
|
8
8
|
import { transcript, summaryLine } from "./transcript.js";
|
|
9
9
|
import { startSession, endSession, setReplyLimit } from "./control.js";
|
|
10
10
|
import { readCredentials } from "./account.js";
|
|
11
|
-
const INSTRUCTIONS = `Parley lets the user hand a
|
|
11
|
+
const INSTRUCTIONS = `Parley lets the user hand a guest a link to a private web chat with a read-only Claude or Codex running in their project folder. The guest talks; the agent listens, asks follow-ups, answers questions from the code, and everything is recorded.
|
|
12
12
|
|
|
13
|
-
Use create_session when the user wants someone to give feedback on a project. Write the brief from what you know about the project and what the user wants to learn. Later, use list_sessions to see which sessions are open, which have new messages, and how many replies each has used and spent; get_transcript to read them; set_reply_limit to give a session more room or less; and end_session when the user is done with one. Links live on parley.im and are end-to-end encrypted; the conversation itself happens on the user's computer, so
|
|
13
|
+
Use create_session when the user wants someone to give feedback on a project. Write the brief from what you know about the project and what the user wants to learn. Later, use list_sessions to see which sessions are open, which have new messages, and how many replies each has used and spent; get_transcript to read them; set_reply_limit to give a session more room or less; and end_session when the user is done with one. Links live on parley.im and are end-to-end encrypted; the conversation itself happens on the user's computer, so guests can only get replies while it's awake and the parley server is running.`;
|
|
14
14
|
export async function runMcp() {
|
|
15
15
|
const config = loadConfig();
|
|
16
16
|
const store = new Store();
|
|
@@ -20,25 +20,25 @@ export async function runMcp() {
|
|
|
20
20
|
const currentLink = (id) => linkFor(store.session(id), config);
|
|
21
21
|
server.registerTool("create_session", {
|
|
22
22
|
title: "Create a feedback session",
|
|
23
|
-
description: "Create a parley session: a private link the user can send to a
|
|
23
|
+
description: "Create a parley session: a private link the user can send to a guest. The guest chats with a fresh, read-only " +
|
|
24
24
|
"agent in the project folder that hears their feedback, answers questions by reading the code, and records everything. " +
|
|
25
25
|
"Starts the local server and tunnel if needed. Returns the link to give the user.",
|
|
26
26
|
inputSchema: {
|
|
27
27
|
project_dir: z.string().describe("Absolute path of the project folder, normally your current working directory."),
|
|
28
|
-
|
|
28
|
+
guest: z.string().describe("Name of the person who will give feedback, e.g. 'Sam'."),
|
|
29
29
|
brief: z
|
|
30
30
|
.string()
|
|
31
31
|
.describe("Instructions for the interviewer, written to it directly. Cover what the project is and its current state, " +
|
|
32
|
-
"how the
|
|
33
|
-
"digging into, and anything about the
|
|
32
|
+
"how the guest will try it (URL, build, steps), what the user most wants to learn, known issues not worth " +
|
|
33
|
+
"digging into, and anything about the guest that helps. A few short paragraphs."),
|
|
34
34
|
greeting: z
|
|
35
35
|
.string()
|
|
36
36
|
.optional()
|
|
37
|
-
.describe("The first message the
|
|
37
|
+
.describe("The first message the guest sees, from the interviewer. Short and warm. A default is used if omitted."),
|
|
38
38
|
agent: z
|
|
39
39
|
.enum(["claude", "codex"])
|
|
40
40
|
.optional()
|
|
41
|
-
.describe("Which agent interviews the
|
|
41
|
+
.describe("Which agent interviews the guest. Defaults to whichever agent is calling this tool."),
|
|
42
42
|
model: z.string().optional().describe("Model override for the interviewer. Usually leave unset."),
|
|
43
43
|
reply_limit: z
|
|
44
44
|
.number()
|
|
@@ -52,7 +52,7 @@ export async function runMcp() {
|
|
|
52
52
|
try {
|
|
53
53
|
made = await startSession(store, config, {
|
|
54
54
|
projectDir: args.project_dir,
|
|
55
|
-
|
|
55
|
+
guest: args.guest,
|
|
56
56
|
brief: args.brief,
|
|
57
57
|
greeting: args.greeting,
|
|
58
58
|
agent: args.agent ?? callerAgent(),
|
|
@@ -65,9 +65,9 @@ export async function runMcp() {
|
|
|
65
65
|
}
|
|
66
66
|
const { session } = made;
|
|
67
67
|
const lines = [
|
|
68
|
-
`Created session ${session.id} for ${session.
|
|
68
|
+
`Created session ${session.id} for ${session.guest} (${session.agent}, read-only in ${session.project_dir}, up to ${session.reply_limit} replies).`,
|
|
69
69
|
"",
|
|
70
|
-
`Link for ${session.
|
|
70
|
+
`Link for ${session.guest}: ${linkFor(session, config)}`,
|
|
71
71
|
`Watch it live on the control panel: ${dashboardUrl(config, session)}`,
|
|
72
72
|
"",
|
|
73
73
|
];
|
|
@@ -81,15 +81,15 @@ export async function runMcp() {
|
|
|
81
81
|
lines.push("Warning: parley.im didn't take the session yet. It will once the connection is back; `parley status` shows it.");
|
|
82
82
|
}
|
|
83
83
|
else {
|
|
84
|
-
lines.push("Everything after the # in the link is the session's encryption key: share the whole link, and only with the
|
|
84
|
+
lines.push("Everything after the # in the link is the session's encryption key: share the whole link, and only with the guest. " +
|
|
85
85
|
"Replies come from this computer, so it needs to be awake with parley running.");
|
|
86
86
|
}
|
|
87
87
|
return reply(lines.join("\n"));
|
|
88
88
|
});
|
|
89
89
|
server.registerTool("list_sessions", {
|
|
90
90
|
title: "List feedback sessions",
|
|
91
|
-
description: "List parley sessions, newest activity first, with how many
|
|
92
|
-
"Open sessions include their current
|
|
91
|
+
description: "List parley sessions, newest activity first, with how many guest messages are new since you last read each one. " +
|
|
92
|
+
"Open sessions include their current guest link.",
|
|
93
93
|
inputSchema: {
|
|
94
94
|
status: z.enum(["open", "ended", "all"]).optional().describe("Defaults to open."),
|
|
95
95
|
project_dir: z.string().optional().describe("Only sessions for this project folder. Omit for every project."),
|
|
@@ -103,14 +103,14 @@ export async function runMcp() {
|
|
|
103
103
|
if (!sessions.length)
|
|
104
104
|
return reply(status === "all" ? "No sessions yet." : `No ${status} sessions.`);
|
|
105
105
|
const down = status !== "ended" && !(await isServerUp(config))
|
|
106
|
-
? "The parley server is not running, so
|
|
106
|
+
? "The parley server is not running, so guests can't get replies. Run `parley start` to bring it back.\n\n"
|
|
107
107
|
: "";
|
|
108
108
|
return reply(down + sessions.map((s) => summaryLine(store, s, linkFor(s, config))).join("\n\n"));
|
|
109
109
|
});
|
|
110
110
|
server.registerTool("get_transcript", {
|
|
111
111
|
title: "Read a feedback session",
|
|
112
112
|
description: "Read a parley session as Markdown: the brief, every message, which model interviewed, and paths to screenshots the " +
|
|
113
|
-
"
|
|
113
|
+
"guest shared. Set only_new to read just what came in since you last read it. Reading marks it read.",
|
|
114
114
|
inputSchema: {
|
|
115
115
|
session_id: z.string(),
|
|
116
116
|
only_new: z.boolean().optional(),
|
|
@@ -123,7 +123,7 @@ export async function runMcp() {
|
|
|
123
123
|
});
|
|
124
124
|
server.registerTool("end_session", {
|
|
125
125
|
title: "End a feedback session",
|
|
126
|
-
description: "End a parley session for good when the user is done with it. The
|
|
126
|
+
description: "End a parley session for good when the user is done with it. The guest's link stops working entirely and an open " +
|
|
127
127
|
"page clears itself. The transcript stays readable here. This can't be undone; make a new session to talk again.",
|
|
128
128
|
inputSchema: { session_id: z.string() },
|
|
129
129
|
}, async (args) => {
|
|
@@ -133,12 +133,12 @@ export async function runMcp() {
|
|
|
133
133
|
if (session.status !== "open")
|
|
134
134
|
return reply(`Session ${session.id} had already ended.`);
|
|
135
135
|
await endSession(config, store, session.id);
|
|
136
|
-
return reply(`Ended ${session.id} (${session.
|
|
136
|
+
return reply(`Ended ${session.id} (${session.guest} on ${session.project_name}). The link no longer works; the transcript is kept. ` +
|
|
137
137
|
`To erase it completely, the user can run \`parley delete ${session.id}\`.`);
|
|
138
138
|
});
|
|
139
139
|
server.registerTool("set_reply_limit", {
|
|
140
140
|
title: "Change a session's reply limit",
|
|
141
|
-
description: "Set how many replies the interviewer may give in a session in total. When a session runs out it pauses: the
|
|
141
|
+
description: "Set how many replies the interviewer may give in a session in total. When a session runs out it pauses: the guest " +
|
|
142
142
|
"can't send more until the limit is raised. Use it when the user wants to give a session more room, or rein it in.",
|
|
143
143
|
inputSchema: { session_id: z.string(), limit: z.number().int().positive().describe("Total replies allowed, including ones already given.") },
|
|
144
144
|
}, async (args) => {
|
|
@@ -147,7 +147,7 @@ export async function runMcp() {
|
|
|
147
147
|
return { ...reply(`No session ${args.session_id}.`), isError: true };
|
|
148
148
|
await setReplyLimit(config, store, session.id, args.limit);
|
|
149
149
|
const used = store.repliesUsed(session.id);
|
|
150
|
-
return reply(`${session.id} (${session.
|
|
150
|
+
return reply(`${session.id} (${session.guest}) may now give ${args.limit} replies in total; ${used} used so far.`);
|
|
151
151
|
});
|
|
152
152
|
await server.connect(new StdioServerTransport());
|
|
153
153
|
}
|
package/dist/models.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
// "claude-opus-5-5"
|
|
2
|
-
//
|
|
1
|
+
// Models by the names their makers use: "claude-opus-5-5" is "Opus 5.5" and
|
|
2
|
+
// "gpt-6-astra" is "GPT-6 Astra". Anything else (aliases, o-series) is shown
|
|
3
|
+
// as it is.
|
|
3
4
|
export function modelLabel(model) {
|
|
4
5
|
if (!model)
|
|
5
6
|
return null;
|
|
6
7
|
const claude = model.match(/^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?$/);
|
|
7
8
|
if (claude)
|
|
8
9
|
return `${claude[1][0].toUpperCase()}${claude[1].slice(1)} ${claude[2]}.${claude[3]}`;
|
|
10
|
+
const gpt = model.match(/^gpt-([\d.]+)((?:-[a-z0-9]+)*)$/);
|
|
11
|
+
if (gpt) {
|
|
12
|
+
const words = gpt[2].split("-").filter(Boolean).map((w) => (/^(mini|nano)$/.test(w) ? w : w[0].toUpperCase() + w.slice(1)));
|
|
13
|
+
return [`GPT-${gpt[1]}`, ...words].join(" ");
|
|
14
|
+
}
|
|
9
15
|
return model;
|
|
10
16
|
}
|
package/dist/relay.js
CHANGED
|
@@ -5,6 +5,7 @@ import { sessionDir } from "./db.js";
|
|
|
5
5
|
import { Problem, MAX_IMAGES } from "./api.js";
|
|
6
6
|
import { readCredentials, relay as registry } from "./account.js";
|
|
7
7
|
import { seal, open, sealBytes, openBytes } from "./seal.js";
|
|
8
|
+
import { VERSION } from "./version.js";
|
|
8
9
|
const IMAGE_TYPES = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp" };
|
|
9
10
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
11
|
const MAX_SEALED_BYTES = 11 * 1024 * 1024;
|
|
@@ -18,7 +19,7 @@ const MAX_HELD_BYTES = 200 * 1024 * 1024;
|
|
|
18
19
|
// A request older than this, or seen before, is a replay and gets refused.
|
|
19
20
|
const FRESH_MS = 5 * 60_000;
|
|
20
21
|
// This computer's connection to parley.im. It connects out (nothing listens
|
|
21
|
-
// on your machine for the internet), answers
|
|
22
|
+
// on your machine for the internet), answers guest pages' encrypted
|
|
22
23
|
// requests, and pushes each session's live events to them, encrypted with
|
|
23
24
|
// that session's key. parley.im only sees which session a frame is for.
|
|
24
25
|
export class RelayLink {
|
|
@@ -33,6 +34,9 @@ export class RelayLink {
|
|
|
33
34
|
retry = null;
|
|
34
35
|
ping = null;
|
|
35
36
|
seen = new Map();
|
|
37
|
+
// parley.im's clock minus ours, from its hello. Guests' pages use the same
|
|
38
|
+
// clock, so a phone or laptop set to the wrong time doesn't matter.
|
|
39
|
+
clock = 0;
|
|
36
40
|
incoming = new Map();
|
|
37
41
|
held = new Map();
|
|
38
42
|
// Events go out in order even though sealing them is asynchronous.
|
|
@@ -71,12 +75,13 @@ export class RelayLink {
|
|
|
71
75
|
return;
|
|
72
76
|
}
|
|
73
77
|
this.status = { state: "connecting", relayUrl: creds.relayUrl, email: creds.account.email };
|
|
74
|
-
const ws = new WebSocket(`${creds.relayUrl.replace(/^http/, "ws")}/api/connect`, ["parley.v1", `parley.token.${creds.token}`]);
|
|
78
|
+
const ws = new WebSocket(`${creds.relayUrl.replace(/^http/, "ws")}/api/connect`, ["parley.v1", `parley.token.${creds.token}`, `parley.version.${VERSION}`]);
|
|
75
79
|
this.ws = ws;
|
|
76
80
|
ws.onopen = async () => {
|
|
77
81
|
this.delay = 1000;
|
|
78
82
|
this.status = { state: "connected", relayUrl: creds.relayUrl, email: creds.account.email, since: new Date().toISOString() };
|
|
79
|
-
|
|
83
|
+
// parley.im counts this computer as gone after about a minute without one.
|
|
84
|
+
this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 20_000);
|
|
80
85
|
// Tell parley.im which sessions are open, so their links work.
|
|
81
86
|
try {
|
|
82
87
|
await registry.sync(creds, this.store.sessions({ status: "open" }));
|
|
@@ -98,6 +103,10 @@ export class RelayLink {
|
|
|
98
103
|
this.status = { state: "unlinked", relayUrl: creds.relayUrl, error: "This computer was unlinked from its parley.im account. Run parley login." };
|
|
99
104
|
return;
|
|
100
105
|
}
|
|
106
|
+
if (event.code === 4426) {
|
|
107
|
+
this.status = { state: "outdated", relayUrl: creds.relayUrl, error: event.reason || "parley on this computer is out of date. Update it with: npm install -g @parley-im/parley" };
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
101
110
|
if (event.code === 4000) {
|
|
102
111
|
this.status = { state: "replaced", relayUrl: creds.relayUrl, error: "Another parley on this computer took over the connection." };
|
|
103
112
|
return;
|
|
@@ -134,6 +143,10 @@ export class RelayLink {
|
|
|
134
143
|
catch {
|
|
135
144
|
return;
|
|
136
145
|
}
|
|
146
|
+
if (frame.t === "hello" && typeof frame.now === "number") {
|
|
147
|
+
this.clock = frame.now - Date.now();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
137
150
|
if (typeof frame.s === "string" && typeof frame.r === "string") {
|
|
138
151
|
if (frame.t === "up")
|
|
139
152
|
return this.receive(frame);
|
|
@@ -156,10 +169,10 @@ export class RelayLink {
|
|
|
156
169
|
// Not sealed with this session's key: tampered with, or a stale link.
|
|
157
170
|
return fail("bad_request");
|
|
158
171
|
}
|
|
159
|
-
if (req.r !== frame.r || typeof req.at !== "number" || Math.abs(Date.now() - req.at) > FRESH_MS || this.replayed(`${frame.s}:${frame.r}`)) {
|
|
172
|
+
if (req.r !== frame.r || typeof req.at !== "number" || Math.abs(Date.now() + this.clock - req.at) > FRESH_MS || this.replayed(`${frame.s}:${frame.r}`)) {
|
|
160
173
|
return fail("stale");
|
|
161
174
|
}
|
|
162
|
-
const reply = async (payload) => this.send({ t: "res", s: frame.s, c: frame.c, r: frame.r, d: await seal(session.e2e_key, session.token,
|
|
175
|
+
const reply = async (payload) => this.send({ t: "res", s: frame.s, c: frame.c, r: frame.r, d: await seal(session.e2e_key, session.token, `res:${frame.r}`, payload) });
|
|
163
176
|
const scope = { session, owner: false, base: "", images: "ref" };
|
|
164
177
|
try {
|
|
165
178
|
if (req.op === "state")
|
|
@@ -169,11 +182,23 @@ export class RelayLink {
|
|
|
169
182
|
if (req.op === "send") {
|
|
170
183
|
const text = String(req.text ?? "").trim();
|
|
171
184
|
const images = Array.isArray(req.images) ? req.images : [];
|
|
185
|
+
const clientId = typeof req.cid === "string" ? req.cid.slice(0, 64) : null;
|
|
186
|
+
const again = this.api.resent(scope, clientId);
|
|
187
|
+
if (again)
|
|
188
|
+
return await reply({ ok: true, result: again });
|
|
172
189
|
this.api.check(scope, text, images.length);
|
|
173
190
|
const names = [];
|
|
174
191
|
for (const image of images.slice(0, MAX_IMAGES))
|
|
175
192
|
names.push(this.saveImage(session, image));
|
|
176
|
-
return await reply({ ok: true, result: this.api.post(scope, text, names) });
|
|
193
|
+
return await reply({ ok: true, result: this.api.post(scope, text, names, clientId) });
|
|
194
|
+
}
|
|
195
|
+
if (req.op === "end") {
|
|
196
|
+
this.api.endByGuest(scope);
|
|
197
|
+
await reply({ ok: true, result: { ended: true } });
|
|
198
|
+
const creds = readCredentials(this.config);
|
|
199
|
+
if (creds)
|
|
200
|
+
await registry.end(creds, session).catch((error) => console.error(`parley.im didn't hear ${session.id} ended: ${error.message}`));
|
|
201
|
+
return;
|
|
177
202
|
}
|
|
178
203
|
return await reply({ ok: false, error: "unknown", message: "Unknown request." });
|
|
179
204
|
}
|
|
@@ -184,7 +209,7 @@ export class RelayLink {
|
|
|
184
209
|
return reply({ ok: false, error: "broken", message: "Something broke on this end." });
|
|
185
210
|
}
|
|
186
211
|
}
|
|
187
|
-
// A screenshot arriving from a
|
|
212
|
+
// A screenshot arriving from a guest's page, in pieces, still encrypted.
|
|
188
213
|
// parley.im only passes it along; it's decrypted and kept here.
|
|
189
214
|
async receive(frame) {
|
|
190
215
|
const now = Date.now();
|
|
@@ -246,7 +271,7 @@ export class RelayLink {
|
|
|
246
271
|
this.held.set(id, { session: session.id, bytes: plain, at: now });
|
|
247
272
|
return id;
|
|
248
273
|
}
|
|
249
|
-
// Keeps a screenshot that a
|
|
274
|
+
// Keeps a screenshot that a guest's message refers to.
|
|
250
275
|
saveImage(session, image) {
|
|
251
276
|
const ext = IMAGE_TYPES[image?.type];
|
|
252
277
|
if (!ext || typeof image.id !== "string" || !/^[A-Za-z0-9_-]{8,40}$/.test(image.id)) {
|
|
@@ -261,7 +286,7 @@ export class RelayLink {
|
|
|
261
286
|
writeFileSync(join(sessionDir(session), name), held.bytes);
|
|
262
287
|
return name;
|
|
263
288
|
}
|
|
264
|
-
// A screenshot for a
|
|
289
|
+
// A screenshot for a guest's page, encrypted here and sent in pieces.
|
|
265
290
|
async serve(frame) {
|
|
266
291
|
const answer = (reply) => this.send({ t: "piece", r: frame.r, end: true, ...reply });
|
|
267
292
|
const session = this.store.sessionByToken(frame.s);
|
|
@@ -273,7 +298,7 @@ export class RelayLink {
|
|
|
273
298
|
: undefined;
|
|
274
299
|
if (!name)
|
|
275
300
|
return answer({ error: "missing", status: 404, message: "No such screenshot." });
|
|
276
|
-
const sealed = await sealBytes(session.e2e_key, session.token,
|
|
301
|
+
const sealed = await sealBytes(session.e2e_key, session.token, `shot:${id}`, readFileSync(join(sessionDir(session), name)));
|
|
277
302
|
for (let at = 0; at < sealed.length; at += PIECE)
|
|
278
303
|
this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
|
|
279
304
|
answer({});
|
package/dist/runner.js
CHANGED
|
@@ -79,8 +79,8 @@ export class Runner {
|
|
|
79
79
|
signal.addEventListener("abort", finish);
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
|
-
submit(session, text, images) {
|
|
83
|
-
const message = this.store.addMessage(session.id, "
|
|
82
|
+
submit(session, text, images, clientId = null) {
|
|
83
|
+
const message = this.store.addMessage(session.id, "guest", text, images, null, clientId);
|
|
84
84
|
this.publish(session.id, { type: "message", message });
|
|
85
85
|
this.kick(session.id);
|
|
86
86
|
return message;
|
|
@@ -97,7 +97,7 @@ export class Runner {
|
|
|
97
97
|
run.cancel();
|
|
98
98
|
}
|
|
99
99
|
// Kills the link for good: any reply in progress stops, open pages clear
|
|
100
|
-
// themselves, and every
|
|
100
|
+
// themselves, and every guest route answers 410 from here on.
|
|
101
101
|
end(sessionId) {
|
|
102
102
|
this.store.end(sessionId);
|
|
103
103
|
this.runs.get(sessionId)?.cancel();
|
|
@@ -179,11 +179,13 @@ export class Runner {
|
|
|
179
179
|
const reason = String(error?.message ?? error).slice(0, 2000);
|
|
180
180
|
console.error(`[${sessionId}] turn ${turn.id} failed: ${reason}`);
|
|
181
181
|
this.store.finishTurn(sessionId, turn.id, "failed", null, reason);
|
|
182
|
-
// Unanswered messages
|
|
183
|
-
//
|
|
184
|
-
this.store.
|
|
182
|
+
// Unanswered messages ride along with the guest's next message,
|
|
183
|
+
// rather than retrying in a loop; a second failure sets them aside.
|
|
184
|
+
const { setAside } = this.store.failTurn(sessionId, turn.id);
|
|
185
185
|
failed = true;
|
|
186
|
-
const message = this.store.addMessage(sessionId, "error",
|
|
186
|
+
const message = this.store.addMessage(sessionId, "error", setAside
|
|
187
|
+
? "I still couldn't reply to that, so I've set it aside. It's saved for the person who sent you this link. Try saying it another way, or send it without the screenshot."
|
|
188
|
+
: "Something went wrong on my end and I couldn't reply. Your message is saved, and I'll see it along with the next one you send.", [], turn.id);
|
|
187
189
|
this.publish(sessionId, { type: "message", message });
|
|
188
190
|
})
|
|
189
191
|
.finally(() => {
|
package/dist/seal.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// End-to-end encryption between your computer and a
|
|
1
|
+
// End-to-end encryption between your computer and a guest's browser.
|
|
2
2
|
//
|
|
3
3
|
// Each session has its own random 256-bit key. It lives in your parley
|
|
4
|
-
// database and in the
|
|
4
|
+
// database and in the guest's link after the "#", which browsers never send
|
|
5
5
|
// to a server, so parley.im only ever carries ciphertext. Everything is
|
|
6
6
|
// AES-256-GCM with a fresh 96-bit IV per message, and bound to the session and
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// purpose so a relay can't replay a reply as a request or move a message
|
|
8
|
+
// between sessions: "req", "evt", "blob" (an upload), a reply to one request
|
|
9
|
+
// ("res:<request id>") and a screenshot by its id ("shot:<id>"), so replies
|
|
10
|
+
// and screenshots can't be swapped for others either. web/seal.js is the
|
|
11
|
+
// browser side of the same format.
|
|
10
12
|
const subtle = globalThis.crypto.subtle;
|
|
11
13
|
const encoder = new TextEncoder();
|
|
12
14
|
const decoder = new TextDecoder();
|