@parley-im/parley 0.1.1 → 0.2.0

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,16 +1,17 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { randomBytes } from "node:crypto";
3
- import { readdirSync, rmSync } from "node:fs";
4
- import { basename, join } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { basename, join, resolve } from "node:path";
5
5
  import { paths, ensureHome } from "./config.js";
6
6
  import { newKey } from "./seal.js";
7
+ import { record } from "./transcript.js";
7
8
  const schema = `
8
9
  create table if not exists sessions (
9
10
  id text primary key,
10
11
  token text not null unique,
11
12
  project_dir text not null,
12
13
  project_name text not null,
13
- tester text not null,
14
+ guest text not null,
14
15
  brief text not null,
15
16
  greeting text not null,
16
17
  agent text not null,
@@ -41,76 +42,296 @@ create table if not exists messages (
41
42
  role text not null,
42
43
  body text not null,
43
44
  images text not null default '[]',
44
- -- For tester messages: the turn that answered it (null while waiting).
45
+ -- For guest messages: the turn that answered it (null while waiting).
45
46
  -- For agent and error messages: the turn that produced it.
46
47
  turn_id integer references turns(id),
48
+ tries integer not null default 0,
49
+ client_id text,
47
50
  created_at text not null
48
51
  );
49
52
  create index if not exists messages_session on messages(session_id, id);
50
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;
51
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
+ ];
52
66
  const now = () => new Date().toISOString();
67
+ // A project's own parley folder: its sessions' database, and a folder per
68
+ // session with the screenshots and a readable transcript. Kept out of git,
69
+ // and out of the interviewer's reach (see agents/).
70
+ export const parleyDir = (projectDir) => join(projectDir, ".parley");
71
+ export const sessionDir = (session) => join(parleyDir(session.project_dir), session.id);
72
+ // Every project you've made parleys in. Each keeps its conversations in its
73
+ // own .parley folder; ~/.parley/projects.json only lists where they are.
53
74
  export class Store {
54
- db;
55
- constructor(file = paths.db) {
75
+ projects = new Map();
76
+ // Which project each session lives in, as they're looked up.
77
+ where = new Map();
78
+ constructor() {
56
79
  ensureHome();
57
- this.db = new DatabaseSync(file);
58
- this.db.exec("pragma journal_mode = wal; pragma busy_timeout = 5000; pragma foreign_keys = on;");
59
- this.db.exec(schema);
60
- this.addMissingColumns();
61
- }
62
- // Columns added since a database was first created.
63
- addMissingColumns() {
64
- const have = new Set(this.db.prepare("pragma table_info(sessions)").all().map((c) => c.name));
65
- if (!have.has("read_through"))
66
- this.db.exec("alter table sessions add column read_through integer not null default 0");
67
- if (!have.has("ended_at"))
68
- this.db.exec("alter table sessions add column ended_at text");
69
- if (!have.has("reply_limit"))
70
- this.db.exec("alter table sessions add column reply_limit integer not null default 100");
71
- if (!have.has("e2e_key"))
72
- this.db.exec("alter table sessions add column e2e_key text");
73
- for (const row of this.db.prepare("select id from sessions where e2e_key is null").all()) {
74
- this.db.prepare("update sessions set e2e_key = ? where id = ?").run(newKey(), row.id);
80
+ this.refresh();
81
+ }
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).
84
+ refresh() {
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);
75
110
  }
76
- this.db.exec("update sessions set status = 'ended', ended_at = coalesce(ended_at, updated_at) where status not in ('open', 'ended')");
111
+ }
112
+ project(dir) {
113
+ dir = realpathSync(dir);
114
+ let db = this.projects.get(dir);
115
+ if (!db) {
116
+ mkdirSync(parleyDir(dir), { recursive: true });
117
+ // Everything in here stays out of git without touching your .gitignore.
118
+ writeFileSync(join(parleyDir(dir), ".gitignore"), "# parley's conversations, kept out of git\n*\n");
119
+ db = new ProjectDb(dir);
120
+ this.projects.set(dir, db);
121
+ const listed = readProjects();
122
+ if (!listed)
123
+ throw new Error(`Can't read ${paths.projects}. Fix or delete it, then try again.`);
124
+ if (!listed.includes(dir))
125
+ writeProjects([...listed, dir]);
126
+ }
127
+ return db;
128
+ }
129
+ of(id) {
130
+ let db = this.where.get(id);
131
+ if (db)
132
+ return db;
133
+ for (const pass of [0, 1]) {
134
+ if (pass)
135
+ this.refresh();
136
+ for (const candidate of this.projects.values()) {
137
+ if (candidate.session(id)) {
138
+ this.where.set(id, candidate);
139
+ return candidate;
140
+ }
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+ // Whichever project holds a session; for one that doesn't exist, nothing.
146
+ on(id, fn, fallback) {
147
+ const db = this.of(id);
148
+ return db ? fn(db) : fallback;
77
149
  }
78
150
  createSession(input) {
79
- const id = shortId();
151
+ let id = shortId();
152
+ while (this.of(id))
153
+ id = shortId();
154
+ const db = this.project(input.projectDir);
155
+ db.createSession(id, input);
156
+ this.where.set(id, db);
157
+ this.addMessage(id, "agent", db.session(id).greeting, [], null);
158
+ return this.session(id);
159
+ }
160
+ session(id) {
161
+ return this.on(id, (db) => db.session(id), undefined);
162
+ }
163
+ sessionByToken(token) {
164
+ for (const pass of [0, 1]) {
165
+ if (pass)
166
+ this.refresh();
167
+ for (const db of this.projects.values()) {
168
+ const session = db.sessionByToken(token);
169
+ if (session)
170
+ return session;
171
+ }
172
+ }
173
+ return undefined;
174
+ }
175
+ sessions(filter = {}) {
176
+ this.refresh();
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()];
179
+ return dbs.flatMap((db) => db.sessions(filter.status)).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
180
+ }
181
+ // Erases the session: transcript, turns, screenshots and its folder.
182
+ deleteSession(id) {
183
+ const session = this.session(id);
184
+ if (!session)
185
+ return;
186
+ this.of(id).deleteSession(id);
187
+ this.where.delete(id);
188
+ rmSync(sessionDir(session), { recursive: true, force: true });
189
+ }
190
+ end(id) {
191
+ this.on(id, (db) => db.end(id), undefined);
192
+ this.write(id);
193
+ }
194
+ setReplyLimit(id, limit) {
195
+ this.on(id, (db) => db.setReplyLimit(id, limit), undefined);
196
+ }
197
+ repliesUsed(id) {
198
+ return this.on(id, (db) => db.repliesUsed(id), 0);
199
+ }
200
+ latestModel(id) {
201
+ return this.on(id, (db) => db.latestModel(id), null);
202
+ }
203
+ markRead(id, messageId) {
204
+ this.on(id, (db) => db.markRead(id, messageId), undefined);
205
+ }
206
+ setAgentSessionId(id, agentSessionId) {
207
+ this.on(id, (db) => db.setAgentSessionId(id, agentSessionId), undefined);
208
+ }
209
+ addMessage(sessionId, role, body, images, turnId, clientId = null) {
210
+ const message = this.of(sessionId).addMessage(sessionId, role, body, images, turnId, clientId);
211
+ this.write(sessionId);
212
+ return message;
213
+ }
214
+ messages(sessionId, afterId = 0) {
215
+ return this.on(sessionId, (db) => db.messages(sessionId, afterId), []);
216
+ }
217
+ page(sessionId, limit, beforeId) {
218
+ return this.on(sessionId, (db) => db.page(sessionId, limit, beforeId), { messages: [], more: false });
219
+ }
220
+ pending(sessionId) {
221
+ return this.on(sessionId, (db) => db.pending(sessionId), []);
222
+ }
223
+ startTurn(session, messageIds) {
224
+ return this.of(session.id).startTurn(session, messageIds);
225
+ }
226
+ turn(sessionId, id) {
227
+ return this.on(sessionId, (db) => db.turn(id), undefined);
228
+ }
229
+ turns(sessionId) {
230
+ return this.on(sessionId, (db) => db.turns(sessionId), []);
231
+ }
232
+ addActivity(sessionId, turnId, line) {
233
+ this.on(sessionId, (db) => db.addActivity(turnId, line), undefined);
234
+ }
235
+ finishTurn(sessionId, turnId, status, usage, error) {
236
+ this.on(sessionId, (db) => db.finishTurn(turnId, status, usage, error), undefined);
237
+ }
238
+ releaseTurn(sessionId, turnId) {
239
+ this.on(sessionId, (db) => db.releaseTurn(turnId), undefined);
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
+ }
247
+ // Turns that never finished (the server stopped mid-reply), everywhere.
248
+ recoverInterrupted() {
249
+ this.refresh();
250
+ return [...this.projects.values()].flatMap((db) => db.recoverInterrupted());
251
+ }
252
+ stats(sessionId) {
253
+ return this.of(sessionId).stats(sessionId);
254
+ }
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.
258
+ write(sessionId) {
259
+ const session = this.session(sessionId);
260
+ if (!session || !existsSync(parleyDir(session.project_dir)))
261
+ return;
262
+ mkdirSync(sessionDir(session), { recursive: true });
263
+ writeFileSync(join(sessionDir(session), "transcript.md"), record(this, session));
264
+ }
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).
268
+ function readProjects() {
269
+ if (!existsSync(paths.projects))
270
+ return [];
271
+ try {
272
+ const list = JSON.parse(readFileSync(paths.projects, "utf8"));
273
+ return Array.isArray(list) ? list.filter((dir) => typeof dir === "string") : null;
274
+ }
275
+ catch {
276
+ return null;
277
+ }
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
+ }
285
+ // One project's sessions, in <project>/.parley/parley.db.
286
+ class ProjectDb {
287
+ db;
288
+ dir;
289
+ constructor(dir) {
290
+ this.dir = dir;
291
+ this.db = new DatabaseSync(join(parleyDir(dir), "parley.db"));
292
+ this.db.exec("pragma journal_mode = wal; pragma busy_timeout = 5000; pragma foreign_keys = on;");
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;`);
299
+ }
300
+ // A project that moved still has its sessions; they're wherever it is now.
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
+ }
311
+ createSession(id, input) {
80
312
  const token = randomBytes(18).toString("base64url");
81
- const projectName = basename(input.projectDir);
313
+ const projectName = basename(this.dir);
82
314
  const greeting = input.greeting?.trim() ||
83
- `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.`;
84
316
  const t = now();
85
317
  this.db
86
- .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)
87
319
  values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`)
88
- .run(id, token, input.projectDir, projectName, input.tester, input.brief, greeting, input.agent, input.model ?? null, input.replyLimit, newKey(), t, t);
89
- this.addMessage(id, "agent", greeting, [], null);
90
- return this.session(id);
320
+ .run(id, token, this.dir, projectName, input.guest, input.brief, greeting, input.agent, input.model ?? null, input.replyLimit, newKey(), t, t);
91
321
  }
92
322
  session(id) {
93
- return this.db.prepare("select * from sessions where id = ?").get(id);
323
+ return this.row(this.db.prepare("select * from sessions where id = ?").get(id));
94
324
  }
95
325
  sessionByToken(token) {
96
- return this.db.prepare("select * from sessions where token = ?").get(token);
326
+ return this.row(this.db.prepare("select * from sessions where token = ?").get(token));
97
327
  }
98
- sessions(filter = {}) {
99
- return this.db
100
- .prepare(`select * from sessions
101
- where (?1 is null or project_dir = ?1) and (?2 is null or status = ?2)
102
- order by updated_at desc`)
103
- .all(filter.projectDir ?? null, filter.status ?? null);
328
+ sessions(status) {
329
+ return this.db.prepare("select * from sessions where (?1 is null or status = ?1)").all(status ?? null).map((r) => this.row(r));
104
330
  }
105
- // Erases the session: transcript, turns, and screenshots.
106
331
  deleteSession(id) {
107
332
  this.db.prepare("delete from messages where session_id = ?").run(id);
108
333
  this.db.prepare("delete from turns where session_id = ?").run(id);
109
334
  this.db.prepare("delete from sessions where id = ?").run(id);
110
- for (const name of readdirSync(paths.uploads)) {
111
- if (name.startsWith(`${id}-`))
112
- rmSync(join(paths.uploads, name), { force: true });
113
- }
114
335
  }
115
336
  end(id) {
116
337
  const t = now();
@@ -140,10 +361,10 @@ export class Store {
140
361
  touch(id) {
141
362
  this.db.prepare("update sessions set updated_at = ? where id = ?").run(now(), id);
142
363
  }
143
- addMessage(sessionId, role, body, images, turnId) {
364
+ addMessage(sessionId, role, body, images, turnId, clientId = null) {
144
365
  const result = this.db
145
- .prepare("insert into messages (session_id, role, body, images, turn_id, created_at) values (?, ?, ?, ?, ?, ?)")
146
- .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());
147
368
  this.touch(sessionId);
148
369
  return this.message(Number(result.lastInsertRowid));
149
370
  }
@@ -164,7 +385,7 @@ export class Store {
164
385
  }
165
386
  pending(sessionId) {
166
387
  return this.db
167
- .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")
168
389
  .all(sessionId).map(parseMessage);
169
390
  }
170
391
  startTurn(session, messageIds) {
@@ -196,7 +417,20 @@ export class Store {
196
417
  .run(status, usage ? JSON.stringify(usage) : null, error, now(), turnId);
197
418
  }
198
419
  releaseTurn(turnId) {
199
- 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);
200
434
  }
201
435
  // A turn that never finished (server stopped mid-reply) gives its messages
202
436
  // back to the queue so they are answered when the server comes back.
@@ -213,11 +447,11 @@ export class Store {
213
447
  stats(sessionId) {
214
448
  const row = this.db
215
449
  .prepare(`select
216
- coalesce(sum(m.role = 'tester'), 0) as tester_messages,
450
+ coalesce(sum(m.role = 'guest'), 0) as guest_messages,
217
451
  coalesce(sum(m.role = 'agent'), 0) as agent_messages,
218
- 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,
219
453
  max(m.created_at) as last_message_at,
220
- 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
221
455
  from sessions s left join messages m on m.session_id = s.id
222
456
  where s.id = ?`)
223
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
  }