@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/README.md +23 -17
- package/bin/parley +7 -0
- package/dist/access.js +57 -0
- package/dist/account.js +5 -1
- package/dist/agents/claude.js +22 -10
- package/dist/agents/codex.js +17 -6
- package/dist/agents/process.js +35 -4
- package/dist/api.js +22 -11
- package/dist/autostart.js +86 -0
- package/dist/charter.js +5 -4
- package/dist/cli.js +64 -17
- package/dist/config.js +53 -12
- package/dist/control.js +16 -10
- package/dist/daemon.js +34 -10
- package/dist/db.js +287 -53
- package/dist/mcp.js +20 -20
- package/dist/models.js +8 -2
- package/dist/relay.js +32 -14
- package/dist/runner.js +15 -13
- package/dist/seal.js +7 -5
- package/dist/server.js +36 -21
- package/dist/state.js +7 -4
- package/dist/transcript.js +24 -11
- package/dist/version.js +15 -0
- package/package.json +2 -2
- package/web/app.css +21 -13
- package/web/chat.html +1 -1
- package/web/chat.js +120 -54
- 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,16 +1,17 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
313
|
+
const projectName = basename(this.dir);
|
|
82
314
|
const greeting = input.greeting?.trim() ||
|
|
83
|
-
`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.`;
|
|
84
316
|
const t = now();
|
|
85
317
|
this.db
|
|
86
|
-
.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)
|
|
87
319
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`)
|
|
88
|
-
.run(id, token,
|
|
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(
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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
|
|
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
|
}
|