@parley-im/parley 0.0.0-stage → 0.1.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/daemon.js ADDED
@@ -0,0 +1,42 @@
1
+ import { spawn } from "node:child_process";
2
+ import { openSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { paths, cleanEnv } from "./config.js";
6
+ import { readState, isServerUp } from "./state.js";
7
+ // cli.ts in a checkout, cli.js once built for npm.
8
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), import.meta.url.endsWith(".ts") ? "cli.ts" : "cli.js");
9
+ // Starts the server in the background if it isn't running, and waits until
10
+ // it answers. It holds this computer's connection to parley.im.
11
+ export async function ensureServer(config, waitMs = 20_000) {
12
+ if (!(await isServerUp(config))) {
13
+ const log = openSync(paths.log, "a");
14
+ const child = spawn(process.execPath, ["--no-warnings", CLI, "serve"], {
15
+ detached: true,
16
+ stdio: ["ignore", log, log],
17
+ env: cleanEnv(),
18
+ cwd: paths.home,
19
+ });
20
+ child.unref();
21
+ }
22
+ const deadline = Date.now() + waitMs;
23
+ while (Date.now() < deadline) {
24
+ const state = readState();
25
+ if (state && (await isServerUp(config)))
26
+ return state;
27
+ await new Promise((r) => setTimeout(r, 400));
28
+ }
29
+ return (await isServerUp(config)) ? readState() : null;
30
+ }
31
+ export async function stopServer() {
32
+ const state = readState();
33
+ if (!state)
34
+ return false;
35
+ try {
36
+ process.kill(state.pid, "SIGTERM");
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
package/dist/db.js ADDED
@@ -0,0 +1,243 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readdirSync, rmSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { paths, ensureHome } from "./config.js";
6
+ import { newKey } from "./seal.js";
7
+ const schema = `
8
+ create table if not exists sessions (
9
+ id text primary key,
10
+ token text not null unique,
11
+ project_dir text not null,
12
+ project_name text not null,
13
+ tester text not null,
14
+ brief text not null,
15
+ greeting text not null,
16
+ agent text not null,
17
+ model text,
18
+ agent_session_id text,
19
+ status text not null default 'open',
20
+ read_through integer not null default 0,
21
+ reply_limit integer not null default 100,
22
+ e2e_key text,
23
+ created_at text not null,
24
+ updated_at text not null,
25
+ ended_at text
26
+ );
27
+ create table if not exists turns (
28
+ id integer primary key autoincrement,
29
+ session_id text not null references sessions(id),
30
+ agent text not null,
31
+ status text not null,
32
+ activity text not null default '[]',
33
+ usage text,
34
+ error text,
35
+ started_at text not null,
36
+ ended_at text
37
+ );
38
+ create table if not exists messages (
39
+ id integer primary key autoincrement,
40
+ session_id text not null references sessions(id),
41
+ role text not null,
42
+ body text not null,
43
+ images text not null default '[]',
44
+ -- For tester messages: the turn that answered it (null while waiting).
45
+ -- For agent and error messages: the turn that produced it.
46
+ turn_id integer references turns(id),
47
+ created_at text not null
48
+ );
49
+ create index if not exists messages_session on messages(session_id, id);
50
+ create index if not exists turns_session on turns(session_id, id);
51
+ `;
52
+ const now = () => new Date().toISOString();
53
+ export class Store {
54
+ db;
55
+ constructor(file = paths.db) {
56
+ 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);
75
+ }
76
+ this.db.exec("update sessions set status = 'ended', ended_at = coalesce(ended_at, updated_at) where status not in ('open', 'ended')");
77
+ }
78
+ createSession(input) {
79
+ const id = shortId();
80
+ const token = randomBytes(18).toString("base64url");
81
+ const projectName = basename(input.projectDir);
82
+ 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.`;
84
+ const t = now();
85
+ 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)
87
+ 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);
91
+ }
92
+ session(id) {
93
+ return this.db.prepare("select * from sessions where id = ?").get(id);
94
+ }
95
+ sessionByToken(token) {
96
+ return this.db.prepare("select * from sessions where token = ?").get(token);
97
+ }
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);
104
+ }
105
+ // Erases the session: transcript, turns, and screenshots.
106
+ deleteSession(id) {
107
+ this.db.prepare("delete from messages where session_id = ?").run(id);
108
+ this.db.prepare("delete from turns where session_id = ?").run(id);
109
+ 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
+ }
115
+ end(id) {
116
+ const t = now();
117
+ this.db.prepare("update sessions set status = 'ended', ended_at = ?, updated_at = ? where id = ? and status = 'open'").run(t, t, id);
118
+ }
119
+ setReplyLimit(id, limit) {
120
+ this.db.prepare("update sessions set reply_limit = ? where id = ?").run(limit, id);
121
+ }
122
+ // Replies the agent has given or tried to give; each one cost something.
123
+ repliesUsed(id) {
124
+ const row = this.db.prepare("select count(*) as n from turns where session_id = ? and status in ('done', 'failed')").get(id);
125
+ return Number(row.n);
126
+ }
127
+ // The model that wrote the latest reply, as the agent reported it.
128
+ latestModel(id) {
129
+ const row = this.db
130
+ .prepare("select json_extract(usage, '$.model') as model from turns where session_id = ? and json_extract(usage, '$.model') is not null order by id desc limit 1")
131
+ .get(id);
132
+ return row?.model ?? null;
133
+ }
134
+ markRead(id, messageId) {
135
+ this.db.prepare("update sessions set read_through = max(read_through, ?) where id = ?").run(messageId, id);
136
+ }
137
+ setAgentSessionId(id, agentSessionId) {
138
+ this.db.prepare("update sessions set agent_session_id = ? where id = ?").run(agentSessionId, id);
139
+ }
140
+ touch(id) {
141
+ this.db.prepare("update sessions set updated_at = ? where id = ?").run(now(), id);
142
+ }
143
+ addMessage(sessionId, role, body, images, turnId) {
144
+ 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());
147
+ this.touch(sessionId);
148
+ return this.message(Number(result.lastInsertRowid));
149
+ }
150
+ message(id) {
151
+ const row = this.db.prepare("select * from messages where id = ?").get(id);
152
+ return row && parseMessage(row);
153
+ }
154
+ messages(sessionId, afterId = 0) {
155
+ return this.db.prepare("select * from messages where session_id = ? and id > ? order by id").all(sessionId, afterId).map(parseMessage);
156
+ }
157
+ // The newest `limit` messages before `beforeId` (or the newest overall),
158
+ // oldest first, and whether there are more before them.
159
+ page(sessionId, limit, beforeId) {
160
+ const rows = this.db
161
+ .prepare("select * from messages where session_id = ? and (?2 is null or id < ?2) order by id desc limit ?3")
162
+ .all(sessionId, beforeId ?? null, limit + 1);
163
+ return { messages: rows.slice(0, limit).reverse().map(parseMessage), more: rows.length > limit };
164
+ }
165
+ pending(sessionId) {
166
+ return this.db
167
+ .prepare("select * from messages where session_id = ? and role = 'tester' and turn_id is null order by id")
168
+ .all(sessionId).map(parseMessage);
169
+ }
170
+ startTurn(session, messageIds) {
171
+ const result = this.db
172
+ .prepare("insert into turns (session_id, agent, status, started_at) values (?, ?, 'running', ?)")
173
+ .run(session.id, session.agent, now());
174
+ const turnId = Number(result.lastInsertRowid);
175
+ const claim = this.db.prepare("update messages set turn_id = ? where id = ?");
176
+ for (const id of messageIds)
177
+ claim.run(turnId, id);
178
+ return this.turn(turnId);
179
+ }
180
+ turn(id) {
181
+ const row = this.db.prepare("select * from turns where id = ?").get(id);
182
+ return row && parseTurn(row);
183
+ }
184
+ turns(sessionId) {
185
+ return this.db.prepare("select * from turns where session_id = ? order by id").all(sessionId).map(parseTurn);
186
+ }
187
+ addActivity(turnId, line) {
188
+ const turn = this.turn(turnId);
189
+ if (!turn)
190
+ return;
191
+ this.db.prepare("update turns set activity = ? where id = ?").run(JSON.stringify([...turn.activity, line]), turnId);
192
+ }
193
+ finishTurn(turnId, status, usage, error) {
194
+ this.db
195
+ .prepare("update turns set status = ?, usage = ?, error = ?, ended_at = ? where id = ?")
196
+ .run(status, usage ? JSON.stringify(usage) : null, error, now(), turnId);
197
+ }
198
+ releaseTurn(turnId) {
199
+ this.db.prepare("update messages set turn_id = null where turn_id = ? and role = 'tester'").run(turnId);
200
+ }
201
+ // A turn that never finished (server stopped mid-reply) gives its messages
202
+ // back to the queue so they are answered when the server comes back.
203
+ recoverInterrupted() {
204
+ const stuck = this.db.prepare("select * from turns where status = 'running'").all();
205
+ const sessions = new Set();
206
+ for (const turn of stuck) {
207
+ this.finishTurn(turn.id, "interrupted", null, "Server stopped before the reply finished.");
208
+ this.releaseTurn(turn.id);
209
+ sessions.add(turn.session_id);
210
+ }
211
+ return [...sessions];
212
+ }
213
+ stats(sessionId) {
214
+ const row = this.db
215
+ .prepare(`select
216
+ coalesce(sum(m.role = 'tester'), 0) as tester_messages,
217
+ coalesce(sum(m.role = 'agent'), 0) as agent_messages,
218
+ coalesce(sum(m.role = 'tester' and m.id > s.read_through), 0) as unread,
219
+ max(m.created_at) as last_message_at,
220
+ max(case when m.role = 'tester' then m.created_at end) as last_tester_at
221
+ from sessions s left join messages m on m.session_id = s.id
222
+ where s.id = ?`)
223
+ .get(sessionId);
224
+ const turns = this.db
225
+ .prepare(`select
226
+ coalesce(sum(status in ('done', 'failed')), 0) as replies,
227
+ coalesce(sum(json_extract(usage, '$.cost_usd')), 0) as cost_usd
228
+ from turns where session_id = ?`)
229
+ .get(sessionId);
230
+ return { ...row, replies: Number(turns.replies), cost_usd: Number(turns.cost_usd) };
231
+ }
232
+ }
233
+ function parseMessage(row) {
234
+ return { ...row, images: JSON.parse(row.images) };
235
+ }
236
+ function parseTurn(row) {
237
+ return { ...row, activity: JSON.parse(row.activity), usage: row.usage ? JSON.parse(row.usage) : null };
238
+ }
239
+ function shortId() {
240
+ const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
241
+ const bytes = randomBytes(6);
242
+ return [...bytes].map((b) => alphabet[b % alphabet.length]).join("");
243
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,153 @@
1
+ import { resolve } from "node:path";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import * as z from "zod";
5
+ import { loadConfig } from "./config.js";
6
+ import { Store } from "./db.js";
7
+ import { linkFor, dashboardUrl, isServerUp } from "./state.js";
8
+ import { transcript, summaryLine } from "./transcript.js";
9
+ import { startSession, endSession, setReplyLimit } from "./control.js";
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.
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.`;
14
+ export async function runMcp() {
15
+ const config = loadConfig();
16
+ const store = new Store();
17
+ const server = new McpServer({ name: "parley", version: "0.1.0" }, { instructions: INSTRUCTIONS });
18
+ const callerAgent = () => (/codex/i.test(server.server.getClientVersion()?.name ?? "") ? "codex" : "claude");
19
+ const reply = (text) => ({ content: [{ type: "text", text }] });
20
+ const currentLink = (id) => linkFor(store.session(id), config);
21
+ server.registerTool("create_session", {
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 " +
24
+ "agent in the project folder that hears their feedback, answers questions by reading the code, and records everything. " +
25
+ "Starts the local server and tunnel if needed. Returns the link to give the user.",
26
+ inputSchema: {
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'."),
29
+ brief: z
30
+ .string()
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."),
34
+ greeting: z
35
+ .string()
36
+ .optional()
37
+ .describe("The first message the tester sees, from the interviewer. Short and warm. A default is used if omitted."),
38
+ agent: z
39
+ .enum(["claude", "codex"])
40
+ .optional()
41
+ .describe("Which agent interviews the tester. Defaults to whichever agent is calling this tool."),
42
+ model: z.string().optional().describe("Model override for the interviewer. Usually leave unset."),
43
+ reply_limit: z
44
+ .number()
45
+ .int()
46
+ .positive()
47
+ .optional()
48
+ .describe(`Replies the interviewer may give before the session pauses for the user to add more. Defaults to ${config.replyLimit}.`),
49
+ },
50
+ }, async (args) => {
51
+ let made;
52
+ try {
53
+ made = await startSession(store, config, {
54
+ projectDir: args.project_dir,
55
+ tester: args.tester,
56
+ brief: args.brief,
57
+ greeting: args.greeting,
58
+ agent: args.agent ?? callerAgent(),
59
+ model: args.model ?? null,
60
+ replyLimit: args.reply_limit ?? null,
61
+ });
62
+ }
63
+ catch (error) {
64
+ return { ...reply(error.message), isError: true };
65
+ }
66
+ const { session } = made;
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).`,
69
+ "",
70
+ `Link for ${session.tester}: ${linkFor(session, config)}`,
71
+ `Watch it live on the control panel: ${dashboardUrl(config, session)}`,
72
+ "",
73
+ ];
74
+ if (!readCredentials(config)) {
75
+ lines.push("This computer isn't signed in to parley.im, so the link only works here. Have the user run `parley login` to share links.");
76
+ }
77
+ else if (!made.serverUp) {
78
+ lines.push("Warning: the parley server didn't come up, so the link won't answer yet. Check ~/.parley/server.log, then run `parley start`.");
79
+ }
80
+ else if (!made.shared) {
81
+ lines.push("Warning: parley.im didn't take the session yet. It will once the connection is back; `parley status` shows it.");
82
+ }
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. " +
85
+ "Replies come from this computer, so it needs to be awake with parley running.");
86
+ }
87
+ return reply(lines.join("\n"));
88
+ });
89
+ server.registerTool("list_sessions", {
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.",
93
+ inputSchema: {
94
+ status: z.enum(["open", "ended", "all"]).optional().describe("Defaults to open."),
95
+ project_dir: z.string().optional().describe("Only sessions for this project folder. Omit for every project."),
96
+ },
97
+ }, async (args) => {
98
+ const status = args.status ?? "open";
99
+ const sessions = store.sessions({
100
+ projectDir: args.project_dir ? resolve(args.project_dir) : undefined,
101
+ status: status === "all" ? undefined : status,
102
+ });
103
+ if (!sessions.length)
104
+ return reply(status === "all" ? "No sessions yet." : `No ${status} sessions.`);
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"
107
+ : "";
108
+ return reply(down + sessions.map((s) => summaryLine(store, s, linkFor(s, config))).join("\n\n"));
109
+ });
110
+ server.registerTool("get_transcript", {
111
+ title: "Read a feedback session",
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.",
114
+ inputSchema: {
115
+ session_id: z.string(),
116
+ only_new: z.boolean().optional(),
117
+ },
118
+ }, async (args) => {
119
+ const session = store.session(args.session_id);
120
+ if (!session)
121
+ return { ...reply(`No session ${args.session_id}.`), isError: true };
122
+ return reply(transcript(store, session, currentLink(session.id), { onlyNew: args.only_new ?? false }));
123
+ });
124
+ server.registerTool("end_session", {
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 " +
127
+ "page clears itself. The transcript stays readable here. This can't be undone; make a new session to talk again.",
128
+ inputSchema: { session_id: z.string() },
129
+ }, async (args) => {
130
+ const session = store.session(args.session_id);
131
+ if (!session)
132
+ return { ...reply(`No session ${args.session_id}.`), isError: true };
133
+ if (session.status !== "open")
134
+ return reply(`Session ${session.id} had already ended.`);
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. ` +
137
+ `To erase it completely, the user can run \`parley delete ${session.id}\`.`);
138
+ });
139
+ server.registerTool("set_reply_limit", {
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 " +
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
+ inputSchema: { session_id: z.string(), limit: z.number().int().positive().describe("Total replies allowed, including ones already given.") },
144
+ }, async (args) => {
145
+ const session = store.session(args.session_id);
146
+ if (!session)
147
+ return { ...reply(`No session ${args.session_id}.`), isError: true };
148
+ await setReplyLimit(config, store, session.id, args.limit);
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.`);
151
+ });
152
+ await server.connect(new StdioServerTransport());
153
+ }
package/dist/models.js ADDED
@@ -0,0 +1,10 @@
1
+ // "claude-opus-5-5" reads better as "Opus 5.5". Anything else (Codex models,
2
+ // aliases) is shown as it is.
3
+ export function modelLabel(model) {
4
+ if (!model)
5
+ return null;
6
+ const claude = model.match(/^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?$/);
7
+ if (claude)
8
+ return `${claude[1][0].toUpperCase()}${claude[1].slice(1)} ${claude[2]}.${claude[3]}`;
9
+ return model;
10
+ }