@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/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
- tester text not null,
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 tester messages: the turn that answered it (null while waiting).
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 readProjects()) {
72
- if (!this.projects.has(dir) && existsSync(join(parleyDir(dir), "parley.db")))
73
- this.projects.set(dir, new ProjectDb(dir));
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 = resolve(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
- writeFileSync(paths.projects, JSON.stringify([...listed, dir], null, 2) + "\n");
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 dbs = filter.projectDir ? [this.projects.get(resolve(filter.projectDir))].filter((db) => !!db) : [...this.projects.values()];
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.exec(schema);
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.tester}! Thanks for trying ${projectName}. Tell me anything: what you tried, what tripped you up, what you liked. Long rambling brain dumps are perfect.`;
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, tester, brief, greeting, agent, model, reply_limit, e2e_key, status, created_at, updated_at)
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.tester, input.brief, greeting, input.agent, input.model ?? null, input.replyLimit, newKey(), t, t);
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 = 'tester' and turn_id is null order by id")
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 = 'tester'").run(turnId);
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 = 'tester'), 0) as tester_messages,
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 = 'tester' and m.id > s.read_through), 0) as unread,
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 = 'tester' then m.created_at end) as last_tester_at
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 tester a link to a private web chat with a read-only Claude or Codex running in their project folder. The tester talks; the agent listens, asks follow-ups, answers questions from the code, and everything is recorded.
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 testers can only get replies while it's awake and the parley server is running.`;
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 tester. The tester chats with a fresh, read-only " +
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
- tester: z.string().describe("Name of the person who will give feedback, e.g. 'Sam'."),
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 tester will try it (URL, build, steps), what the user most wants to learn, known issues not worth " +
33
- "digging into, and anything about the tester that helps. A few short paragraphs."),
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 tester sees, from the interviewer. Short and warm. A default is used if omitted."),
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 tester. Defaults to whichever agent is calling this tool."),
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
- tester: args.tester,
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.tester} (${session.agent}, read-only in ${session.project_dir}, up to ${session.reply_limit} replies).`,
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.tester}: ${linkFor(session, config)}`,
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 tester. " +
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 tester messages are new since you last read each one. " +
92
- "Open sessions include their current tester link.",
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 testers can't get replies. Run `parley start` to bring it back.\n\n"
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
- "tester shared. Set only_new to read just what came in since you last read it. Reading marks it read.",
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 tester's link stops working entirely and an open " +
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.tester} on ${session.project_name}). The link no longer works; the transcript is kept. ` +
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 tester " +
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.tester}) may now give ${args.limit} replies in total; ${used} used so far.`);
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" reads better as "Opus 5.5". Anything else (Codex models,
2
- // aliases) is shown as it is.
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 tester pages' encrypted
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
- this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 30_000);
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, "res", payload) });
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 tester's page, in pieces, still encrypted.
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 tester's message refers to.
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 tester's page, encrypted here and sent in pieces.
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, "blob", readFileSync(join(sessionDir(session), name)));
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, "tester", text, images, null);
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 tester route answers 410 from here on.
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 go back in the queue and ride along with the
183
- // tester's next message, rather than retrying in a loop.
184
- this.store.releaseTurn(sessionId, turn.id);
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", "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);
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 tester's browser.
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 tester's link after the "#", which browsers never send
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
- // direction ("req", "res", "evt", "blob") so a relay can't replay a reply as
8
- // a request or move a message between sessions. web/seal.js is the browser
9
- // side of the same format.
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();