@parley-im/parley 0.1.1 → 0.1.2
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 +7 -3
- package/dist/agents/claude.js +7 -4
- package/dist/agents/codex.js +12 -6
- package/dist/api.js +1 -1
- package/dist/charter.js +1 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +4 -3
- package/dist/db.js +191 -41
- package/dist/relay.js +8 -6
- package/dist/runner.js +7 -7
- package/dist/server.js +6 -5
- package/dist/transcript.js +15 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,8 +63,10 @@ A chat page built for phones first, in a plain technical style (a transcript, no
|
|
|
63
63
|
|
|
64
64
|
Each session is a fresh agent conversation in the project folder, guided by a charter (`src/charter.ts`), your brief, and the project's CLAUDE.md and AGENTS.md. It talks about the product as a user sees it and only gets into files and internals when asked.
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
It can read the project folder and nothing else on your computer, and not the project's `.parley` folder, where other people's conversations are. That's enforced by each agent's own sandbox, not just asked of it (the charter asks too):
|
|
67
|
+
|
|
68
|
+
- **Claude:** `claude -p --restricted --tools Read,Grep,Glob --strict-mcp-config`, plus a `Read(./.parley/**)` deny rule. No command, write or web tools; `--restricted` confines the file tools to the project; your MCP servers are left out.
|
|
69
|
+
- **Codex:** `codex exec --ignore-user-config` under a permission profile that can read the project and the system files tools need (`:minimal`), with `.parley` denied, no writes and no network. Apps, browser, computer use, plugins, hooks and web search are off; your model and reasoning effort carry over. Codex re-runs itself inside that sandbox, so parley starts it by its real path and lets it read that one file.
|
|
68
70
|
|
|
69
71
|
Each reply records which model answered, on which login (`auth: "none"` means your subscription, not an API key), and what it would cost at API prices.
|
|
70
72
|
|
|
@@ -90,7 +92,7 @@ Each reply records which model answered, on which login (`auth: "none"` means yo
|
|
|
90
92
|
| `src/account.ts` | `parley login`, credentials, and what parley.im is told |
|
|
91
93
|
| `src/runner.ts`, `src/agents/` | One reply at a time per session; Claude and Codex adapters |
|
|
92
94
|
| `src/mcp.ts` | `create_session`, `list_sessions`, `get_transcript`, `set_reply_limit`, `end_session` |
|
|
93
|
-
| `src/db.ts` |
|
|
95
|
+
| `src/db.ts` | Each project's `.parley/parley.db` (sessions with keys, messages, turns), a folder per session with screenshots and `transcript.md`, and `~/.parley/projects.json` listing the projects |
|
|
94
96
|
| `web/` | Chat page, session view, control panel; parley.im serves the tester page's files from here too |
|
|
95
97
|
| `relay/` | parley.im: Worker, a Durable Object per linked computer, D1; the front page's animation is `web/home.js` |
|
|
96
98
|
|
|
@@ -112,6 +114,8 @@ The npm package is `@parley-im/parley` (the parley-im org; plain `parley` is tak
|
|
|
112
114
|
|
|
113
115
|
## Config
|
|
114
116
|
|
|
117
|
+
Conversations live with their project, in `<project>/.parley/`: a database, and a folder per session holding its screenshots and a `transcript.md` your agent can read. The folder carries its own `.gitignore`, so it stays out of git. `~/.parley` keeps only settings, your parley.im login, the server's state and log, and the list of projects.
|
|
118
|
+
|
|
115
119
|
`~/.parley/config.json`: `relayUrl` (default `https://parley.im`), `ownerName` (defaults to your account's first name), `replyLimit`, ports, and `claude`/`codex` model and effort overrides. Without `parley login`, links are local-only (`http://localhost:4747/...`), for development.
|
|
116
120
|
|
|
117
121
|
## Running parley.im
|
package/dist/agents/claude.js
CHANGED
|
@@ -3,10 +3,12 @@ import { sep } from "node:path";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { runJsonLines } from "./process.js";
|
|
5
5
|
// Claude Code in print mode, one process per turn, resumed by session id.
|
|
6
|
-
// --restricted drops every tool that runs code
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// --restricted drops every tool that runs code, ignores your settings files
|
|
7
|
+
// and confines the file tools to the project folder; --tools narrows it to
|
|
8
|
+
// reading, a deny rule keeps it out of the project's .parley folder (other
|
|
9
|
+
// people's conversations), and --strict-mcp-config keeps your MCP servers
|
|
10
|
+
// (mail, calendar, ...) out of reach. The project's CLAUDE.md and AGENTS.md
|
|
11
|
+
// reach it through the charter.
|
|
10
12
|
export function runClaudeTurn(input, events) {
|
|
11
13
|
const { session, config } = input;
|
|
12
14
|
const agentSessionId = session.agent_session_id ?? randomUUID();
|
|
@@ -21,6 +23,7 @@ export function runClaudeTurn(input, events) {
|
|
|
21
23
|
"--strict-mcp-config",
|
|
22
24
|
"--permission-prompts", "none",
|
|
23
25
|
"--disable-slash-commands",
|
|
26
|
+
"--settings", JSON.stringify({ permissions: { deny: ["Read(./.parley/**)", `Read(/${session.project_dir}/.parley/**)`] } }),
|
|
24
27
|
"--append-system-prompt", input.charter,
|
|
25
28
|
"--name", `parley: ${session.tester}`,
|
|
26
29
|
];
|
package/dist/agents/codex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { runJsonLines } from "./process.js";
|
|
@@ -17,21 +17,27 @@ const DISABLED_FEATURES = [
|
|
|
17
17
|
];
|
|
18
18
|
// Codex exec, one process per turn, resumed by thread id. Your config.toml is
|
|
19
19
|
// ignored (it carries your full-access sandbox and MCP servers); only its
|
|
20
|
-
// model and reasoning effort are carried over.
|
|
21
|
-
//
|
|
20
|
+
// model and reasoning effort are carried over. Its commands run under a
|
|
21
|
+
// permission profile that can read the project and the system files tools
|
|
22
|
+
// need, and nothing else: not the rest of your home folder, not other
|
|
23
|
+
// projects, and not the project's .parley folder, where other people's
|
|
24
|
+
// conversations are. No writes, no network. Codex re-runs itself inside that
|
|
25
|
+
// sandbox, so it's started by its real path and may read that one file.
|
|
26
|
+
const profile = (bin) => `permissions.parley={filesystem={":minimal"="read", ${JSON.stringify(bin)}="read", ":workspace_roots"={"."="read", ".parley"="deny"}}}`;
|
|
22
27
|
export function runCodexTurn(input, events) {
|
|
23
28
|
const { session, config } = input;
|
|
24
29
|
const defaults = readCodexDefaults();
|
|
25
30
|
const model = session.model ?? config.codex.model ?? defaults.model;
|
|
26
31
|
const effort = config.codex.effort ?? defaults.effort;
|
|
32
|
+
const bin = existsSync(config.codex.bin) ? realpathSync(config.codex.bin) : config.codex.bin;
|
|
27
33
|
const args = [
|
|
28
34
|
"exec",
|
|
29
35
|
"--json",
|
|
30
36
|
"--ignore-user-config",
|
|
31
37
|
"--skip-git-repo-check",
|
|
32
|
-
"--sandbox", "read-only",
|
|
33
38
|
"--cd", session.project_dir,
|
|
34
|
-
"-c", `
|
|
39
|
+
"-c", `default_permissions="parley"`,
|
|
40
|
+
"-c", profile(bin),
|
|
35
41
|
"-c", `approval_policy="never"`,
|
|
36
42
|
"-c", `web_search="disabled"`,
|
|
37
43
|
"-c", `developer_instructions=${JSON.stringify(input.charter)}`,
|
|
@@ -54,7 +60,7 @@ export function runCodexTurn(input, events) {
|
|
|
54
60
|
let threadId = null;
|
|
55
61
|
const commands = new Set();
|
|
56
62
|
const proc = runJsonLines({
|
|
57
|
-
bin
|
|
63
|
+
bin,
|
|
58
64
|
args,
|
|
59
65
|
cwd: session.project_dir,
|
|
60
66
|
stdin: input.text,
|
package/dist/api.js
CHANGED
|
@@ -157,7 +157,7 @@ export class Conversations {
|
|
|
157
157
|
};
|
|
158
158
|
if (!scope.owner || m.role !== "agent" || !m.turn_id)
|
|
159
159
|
return view;
|
|
160
|
-
const turn = this.store.turn(m.turn_id);
|
|
160
|
+
const turn = this.store.turn(m.session_id, m.turn_id);
|
|
161
161
|
if (!turn)
|
|
162
162
|
return view;
|
|
163
163
|
const usage = turn.usage ?? {};
|
package/dist/charter.js
CHANGED
|
@@ -16,6 +16,7 @@ How to run it:
|
|
|
16
16
|
- Capture their perspective faithfully. Do not argue, defend the design, or talk them out of a reaction. If something is a known limitation, say so plainly.
|
|
17
17
|
- Do not promise fixes, features, or dates. Say you will pass it along to ${owner}.
|
|
18
18
|
- You are read-only. You can read files but cannot edit them or run anything. If they ask you to fix or change something, explain that this session is for collecting feedback and that you will make sure ${owner} sees it.
|
|
19
|
+
- This project's folder is all you can see. Stay inside it: don't look for or talk about anything else on ${owner}'s computer, even if asked. Its .parley folder holds other people's conversations, so never open it or repeat anything from it.
|
|
19
20
|
- Keep replies short and conversational, like a good interviewer: a sentence or two plus at most one question. They are often on a phone. Light formatting only. Never use em dashes.
|
|
20
21
|
- Don't narrate what you're doing ("let me check the code", "looking at the file now"). Look things up quietly and just answer.
|
|
21
22
|
- They may paste screenshots. Look at them closely and refer to what you see.
|
package/dist/cli.js
CHANGED
|
@@ -41,7 +41,8 @@ Commands
|
|
|
41
41
|
parley install Add the parley MCP server to Claude Code and Codex
|
|
42
42
|
parley serve | mcp Run the server in the foreground | MCP over stdio
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Conversations and screenshots live in each project's .parley folder (kept out of git), with a
|
|
45
|
+
readable transcript.md per session. Settings and your parley.im login live in ${paths.home}.`;
|
|
45
46
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
46
47
|
async function main() {
|
|
47
48
|
const [command, ...rest] = process.argv.slice(2);
|
package/dist/config.js
CHANGED
|
@@ -2,13 +2,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, accessSync, constan
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, delimiter } from "node:path";
|
|
4
4
|
export const HOME = process.env.PARLEY_HOME ?? join(homedir(), ".parley");
|
|
5
|
+
// Settings, your parley.im login and the server's state. Conversations live
|
|
6
|
+
// in each project's own .parley folder (see db.ts); projects.json lists them.
|
|
5
7
|
export const paths = {
|
|
6
8
|
home: HOME,
|
|
7
9
|
config: join(HOME, "config.json"),
|
|
8
|
-
|
|
10
|
+
projects: join(HOME, "projects.json"),
|
|
9
11
|
state: join(HOME, "server.json"),
|
|
10
12
|
log: join(HOME, "server.log"),
|
|
11
|
-
uploads: join(HOME, "uploads"),
|
|
12
13
|
// Your computer's link to a parley.im account, from `parley login`.
|
|
13
14
|
credentials: join(HOME, "credentials.json"),
|
|
14
15
|
};
|
|
@@ -22,7 +23,7 @@ const defaults = {
|
|
|
22
23
|
codex: { bin: "codex", model: null, effort: null },
|
|
23
24
|
};
|
|
24
25
|
export function ensureHome() {
|
|
25
|
-
mkdirSync(paths.
|
|
26
|
+
mkdirSync(paths.home, { recursive: true });
|
|
26
27
|
}
|
|
27
28
|
export function loadConfig() {
|
|
28
29
|
ensureHome();
|
package/dist/db.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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, 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,
|
|
@@ -50,67 +51,216 @@ create index if not exists messages_session on messages(session_id, id);
|
|
|
50
51
|
create index if not exists turns_session on turns(session_id, id);
|
|
51
52
|
`;
|
|
52
53
|
const now = () => new Date().toISOString();
|
|
54
|
+
// A project's own parley folder: its sessions' database, and a folder per
|
|
55
|
+
// session with the screenshots and a readable transcript. Kept out of git,
|
|
56
|
+
// and out of the interviewer's reach (see agents/).
|
|
57
|
+
export const parleyDir = (projectDir) => join(projectDir, ".parley");
|
|
58
|
+
export const sessionDir = (session) => join(parleyDir(session.project_dir), session.id);
|
|
59
|
+
// Every project you've made parleys in. Each keeps its conversations in its
|
|
60
|
+
// own .parley folder; ~/.parley/projects.json only lists where they are.
|
|
53
61
|
export class Store {
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
projects = new Map();
|
|
63
|
+
// Which project each session lives in, as they're looked up.
|
|
64
|
+
where = new Map();
|
|
65
|
+
constructor() {
|
|
56
66
|
ensureHome();
|
|
57
|
-
this.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
67
|
+
this.refresh();
|
|
68
|
+
}
|
|
69
|
+
// Opens any project another parley process has added since.
|
|
70
|
+
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));
|
|
75
74
|
}
|
|
76
|
-
|
|
75
|
+
}
|
|
76
|
+
project(dir) {
|
|
77
|
+
dir = resolve(dir);
|
|
78
|
+
let db = this.projects.get(dir);
|
|
79
|
+
if (!db) {
|
|
80
|
+
mkdirSync(parleyDir(dir), { recursive: true });
|
|
81
|
+
// Everything in here stays out of git without touching your .gitignore.
|
|
82
|
+
writeFileSync(join(parleyDir(dir), ".gitignore"), "# parley's conversations, kept out of git\n*\n");
|
|
83
|
+
db = new ProjectDb(dir);
|
|
84
|
+
this.projects.set(dir, db);
|
|
85
|
+
const listed = readProjects();
|
|
86
|
+
if (!listed.includes(dir))
|
|
87
|
+
writeFileSync(paths.projects, JSON.stringify([...listed, dir], null, 2) + "\n");
|
|
88
|
+
}
|
|
89
|
+
return db;
|
|
90
|
+
}
|
|
91
|
+
of(id) {
|
|
92
|
+
let db = this.where.get(id);
|
|
93
|
+
if (db)
|
|
94
|
+
return db;
|
|
95
|
+
for (const pass of [0, 1]) {
|
|
96
|
+
if (pass)
|
|
97
|
+
this.refresh();
|
|
98
|
+
for (const candidate of this.projects.values()) {
|
|
99
|
+
if (candidate.session(id)) {
|
|
100
|
+
this.where.set(id, candidate);
|
|
101
|
+
return candidate;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
// Whichever project holds a session; for one that doesn't exist, nothing.
|
|
108
|
+
on(id, fn, fallback) {
|
|
109
|
+
const db = this.of(id);
|
|
110
|
+
return db ? fn(db) : fallback;
|
|
77
111
|
}
|
|
78
112
|
createSession(input) {
|
|
79
|
-
|
|
113
|
+
let id = shortId();
|
|
114
|
+
while (this.of(id))
|
|
115
|
+
id = shortId();
|
|
116
|
+
const db = this.project(input.projectDir);
|
|
117
|
+
db.createSession(id, input);
|
|
118
|
+
this.where.set(id, db);
|
|
119
|
+
this.addMessage(id, "agent", db.session(id).greeting, [], null);
|
|
120
|
+
return this.session(id);
|
|
121
|
+
}
|
|
122
|
+
session(id) {
|
|
123
|
+
return this.on(id, (db) => db.session(id), undefined);
|
|
124
|
+
}
|
|
125
|
+
sessionByToken(token) {
|
|
126
|
+
for (const pass of [0, 1]) {
|
|
127
|
+
if (pass)
|
|
128
|
+
this.refresh();
|
|
129
|
+
for (const db of this.projects.values()) {
|
|
130
|
+
const session = db.sessionByToken(token);
|
|
131
|
+
if (session)
|
|
132
|
+
return session;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
sessions(filter = {}) {
|
|
138
|
+
this.refresh();
|
|
139
|
+
const dbs = filter.projectDir ? [this.projects.get(resolve(filter.projectDir))].filter((db) => !!db) : [...this.projects.values()];
|
|
140
|
+
return dbs.flatMap((db) => db.sessions(filter.status)).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
141
|
+
}
|
|
142
|
+
// Erases the session: transcript, turns, screenshots and its folder.
|
|
143
|
+
deleteSession(id) {
|
|
144
|
+
const session = this.session(id);
|
|
145
|
+
if (!session)
|
|
146
|
+
return;
|
|
147
|
+
this.of(id).deleteSession(id);
|
|
148
|
+
this.where.delete(id);
|
|
149
|
+
rmSync(sessionDir(session), { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
end(id) {
|
|
152
|
+
this.on(id, (db) => db.end(id), undefined);
|
|
153
|
+
this.write(id);
|
|
154
|
+
}
|
|
155
|
+
setReplyLimit(id, limit) {
|
|
156
|
+
this.on(id, (db) => db.setReplyLimit(id, limit), undefined);
|
|
157
|
+
}
|
|
158
|
+
repliesUsed(id) {
|
|
159
|
+
return this.on(id, (db) => db.repliesUsed(id), 0);
|
|
160
|
+
}
|
|
161
|
+
latestModel(id) {
|
|
162
|
+
return this.on(id, (db) => db.latestModel(id), null);
|
|
163
|
+
}
|
|
164
|
+
markRead(id, messageId) {
|
|
165
|
+
this.on(id, (db) => db.markRead(id, messageId), undefined);
|
|
166
|
+
}
|
|
167
|
+
setAgentSessionId(id, agentSessionId) {
|
|
168
|
+
this.on(id, (db) => db.setAgentSessionId(id, agentSessionId), undefined);
|
|
169
|
+
}
|
|
170
|
+
addMessage(sessionId, role, body, images, turnId) {
|
|
171
|
+
const message = this.of(sessionId).addMessage(sessionId, role, body, images, turnId);
|
|
172
|
+
this.write(sessionId);
|
|
173
|
+
return message;
|
|
174
|
+
}
|
|
175
|
+
messages(sessionId, afterId = 0) {
|
|
176
|
+
return this.on(sessionId, (db) => db.messages(sessionId, afterId), []);
|
|
177
|
+
}
|
|
178
|
+
page(sessionId, limit, beforeId) {
|
|
179
|
+
return this.on(sessionId, (db) => db.page(sessionId, limit, beforeId), { messages: [], more: false });
|
|
180
|
+
}
|
|
181
|
+
pending(sessionId) {
|
|
182
|
+
return this.on(sessionId, (db) => db.pending(sessionId), []);
|
|
183
|
+
}
|
|
184
|
+
startTurn(session, messageIds) {
|
|
185
|
+
return this.of(session.id).startTurn(session, messageIds);
|
|
186
|
+
}
|
|
187
|
+
turn(sessionId, id) {
|
|
188
|
+
return this.on(sessionId, (db) => db.turn(id), undefined);
|
|
189
|
+
}
|
|
190
|
+
turns(sessionId) {
|
|
191
|
+
return this.on(sessionId, (db) => db.turns(sessionId), []);
|
|
192
|
+
}
|
|
193
|
+
addActivity(sessionId, turnId, line) {
|
|
194
|
+
this.on(sessionId, (db) => db.addActivity(turnId, line), undefined);
|
|
195
|
+
}
|
|
196
|
+
finishTurn(sessionId, turnId, status, usage, error) {
|
|
197
|
+
this.on(sessionId, (db) => db.finishTurn(turnId, status, usage, error), undefined);
|
|
198
|
+
}
|
|
199
|
+
releaseTurn(sessionId, turnId) {
|
|
200
|
+
this.on(sessionId, (db) => db.releaseTurn(turnId), undefined);
|
|
201
|
+
}
|
|
202
|
+
// Turns that never finished (the server stopped mid-reply), everywhere.
|
|
203
|
+
recoverInterrupted() {
|
|
204
|
+
this.refresh();
|
|
205
|
+
return [...this.projects.values()].flatMap((db) => db.recoverInterrupted());
|
|
206
|
+
}
|
|
207
|
+
stats(sessionId) {
|
|
208
|
+
return this.of(sessionId).stats(sessionId);
|
|
209
|
+
}
|
|
210
|
+
// Keeps <session>/transcript.md in step, for you and your agent to read.
|
|
211
|
+
write(sessionId) {
|
|
212
|
+
const session = this.session(sessionId);
|
|
213
|
+
if (!session)
|
|
214
|
+
return;
|
|
215
|
+
mkdirSync(sessionDir(session), { recursive: true });
|
|
216
|
+
writeFileSync(join(sessionDir(session), "transcript.md"), record(this, session));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function readProjects() {
|
|
220
|
+
try {
|
|
221
|
+
const list = JSON.parse(readFileSync(paths.projects, "utf8"));
|
|
222
|
+
return Array.isArray(list) ? list.filter((dir) => typeof dir === "string") : [];
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// One project's sessions, in <project>/.parley/parley.db.
|
|
229
|
+
class ProjectDb {
|
|
230
|
+
db;
|
|
231
|
+
dir;
|
|
232
|
+
constructor(dir) {
|
|
233
|
+
this.dir = dir;
|
|
234
|
+
this.db = new DatabaseSync(join(parleyDir(dir), "parley.db"));
|
|
235
|
+
this.db.exec("pragma journal_mode = wal; pragma busy_timeout = 5000; pragma foreign_keys = on;");
|
|
236
|
+
this.db.exec(schema);
|
|
237
|
+
}
|
|
238
|
+
// A project that moved still has its sessions; they're wherever it is now.
|
|
239
|
+
row = (row) => row && { ...row, project_dir: this.dir };
|
|
240
|
+
createSession(id, input) {
|
|
80
241
|
const token = randomBytes(18).toString("base64url");
|
|
81
|
-
const projectName = basename(
|
|
242
|
+
const projectName = basename(this.dir);
|
|
82
243
|
const greeting = input.greeting?.trim() ||
|
|
83
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.`;
|
|
84
245
|
const t = now();
|
|
85
246
|
this.db
|
|
86
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)
|
|
87
248
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`)
|
|
88
|
-
.run(id, token,
|
|
89
|
-
this.addMessage(id, "agent", greeting, [], null);
|
|
90
|
-
return this.session(id);
|
|
249
|
+
.run(id, token, this.dir, projectName, input.tester, input.brief, greeting, input.agent, input.model ?? null, input.replyLimit, newKey(), t, t);
|
|
91
250
|
}
|
|
92
251
|
session(id) {
|
|
93
|
-
return this.db.prepare("select * from sessions where id = ?").get(id);
|
|
252
|
+
return this.row(this.db.prepare("select * from sessions where id = ?").get(id));
|
|
94
253
|
}
|
|
95
254
|
sessionByToken(token) {
|
|
96
|
-
return this.db.prepare("select * from sessions where token = ?").get(token);
|
|
255
|
+
return this.row(this.db.prepare("select * from sessions where token = ?").get(token));
|
|
97
256
|
}
|
|
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);
|
|
257
|
+
sessions(status) {
|
|
258
|
+
return this.db.prepare("select * from sessions where (?1 is null or status = ?1)").all(status ?? null).map((r) => this.row(r));
|
|
104
259
|
}
|
|
105
|
-
// Erases the session: transcript, turns, and screenshots.
|
|
106
260
|
deleteSession(id) {
|
|
107
261
|
this.db.prepare("delete from messages where session_id = ?").run(id);
|
|
108
262
|
this.db.prepare("delete from turns where session_id = ?").run(id);
|
|
109
263
|
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
264
|
}
|
|
115
265
|
end(id) {
|
|
116
266
|
const t = now();
|
package/dist/relay.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
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";
|
|
@@ -230,7 +230,8 @@ export class RelayLink {
|
|
|
230
230
|
}
|
|
231
231
|
if (plain.length > MAX_IMAGE_BYTES)
|
|
232
232
|
throw new Problem(413, "too_big", "That image is too big. The limit is 10 MB.");
|
|
233
|
-
const
|
|
233
|
+
const dir = sessionDir(session);
|
|
234
|
+
const kept = existsSync(dir) ? readdirSync(dir).filter((name) => name.startsWith(`${session.id}-`)).length : 0;
|
|
234
235
|
if (kept >= MAX_SHOTS)
|
|
235
236
|
throw new Problem(429, "too_many", "This session can't take more screenshots.");
|
|
236
237
|
const now = Date.now();
|
|
@@ -256,7 +257,8 @@ export class RelayLink {
|
|
|
256
257
|
throw new Problem(400, "bad_image", "That screenshot didn't arrive. Try adding it again.");
|
|
257
258
|
this.held.delete(image.id);
|
|
258
259
|
const name = `${session.id}-${image.id}${ext}`;
|
|
259
|
-
|
|
260
|
+
mkdirSync(sessionDir(session), { recursive: true });
|
|
261
|
+
writeFileSync(join(sessionDir(session), name), held.bytes);
|
|
260
262
|
return name;
|
|
261
263
|
}
|
|
262
264
|
// A screenshot for a tester's page, encrypted here and sent in pieces.
|
|
@@ -267,11 +269,11 @@ export class RelayLink {
|
|
|
267
269
|
return answer({ error: "ended", status: 410, message: "This parley has ended." });
|
|
268
270
|
const id = String(frame.id ?? "");
|
|
269
271
|
const name = /^[A-Za-z0-9_-]{8,40}$/.test(id)
|
|
270
|
-
? Object.values(IMAGE_TYPES).map((ext) => `${session.id}-${id}${ext}`).find((n) => existsSync(join(
|
|
272
|
+
? Object.values(IMAGE_TYPES).map((ext) => `${session.id}-${id}${ext}`).find((n) => existsSync(join(sessionDir(session), n)))
|
|
271
273
|
: undefined;
|
|
272
274
|
if (!name)
|
|
273
275
|
return answer({ error: "missing", status: 404, message: "No such screenshot." });
|
|
274
|
-
const sealed = await sealBytes(session.e2e_key, session.token, "blob", readFileSync(join(
|
|
276
|
+
const sealed = await sealBytes(session.e2e_key, session.token, "blob", readFileSync(join(sessionDir(session), name)));
|
|
275
277
|
for (let at = 0; at < sealed.length; at += PIECE)
|
|
276
278
|
this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
|
|
277
279
|
answer({});
|
package/dist/runner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join, extname } from "node:path";
|
|
2
|
-
import { paths } from "./config.js";
|
|
3
2
|
import { charter } from "./charter.js";
|
|
3
|
+
import { sessionDir } from "./db.js";
|
|
4
4
|
import { runClaudeTurn } from "./agents/claude.js";
|
|
5
5
|
import { runCodexTurn } from "./agents/codex.js";
|
|
6
6
|
import { modelLabel } from "./models.js";
|
|
@@ -136,7 +136,7 @@ export class Runner {
|
|
|
136
136
|
const text = pending.map((m) => m.body).join("\n\n");
|
|
137
137
|
const images = pending
|
|
138
138
|
.flatMap((m) => m.images)
|
|
139
|
-
.map((file) => ({ path: join(
|
|
139
|
+
.map((file) => ({ path: join(sessionDir(session), file), mediaType: mediaTypeFor(file) }))
|
|
140
140
|
.filter((image) => image.mediaType);
|
|
141
141
|
const input = { session, config: this.config, charter: charter(session, this.config), text, images };
|
|
142
142
|
const events = {
|
|
@@ -151,7 +151,7 @@ export class Runner {
|
|
|
151
151
|
},
|
|
152
152
|
onActivity: (line) => {
|
|
153
153
|
live.activity = line;
|
|
154
|
-
this.store.addActivity(turn.id, line);
|
|
154
|
+
this.store.addActivity(sessionId, turn.id, line);
|
|
155
155
|
this.publish(sessionId, { type: "activity", line });
|
|
156
156
|
},
|
|
157
157
|
};
|
|
@@ -166,22 +166,22 @@ export class Runner {
|
|
|
166
166
|
let failed = false;
|
|
167
167
|
run.done
|
|
168
168
|
.then((result) => {
|
|
169
|
-
this.store.finishTurn(turn.id, "done", result.usage, null);
|
|
169
|
+
this.store.finishTurn(sessionId, turn.id, "done", result.usage, null);
|
|
170
170
|
const message = this.store.addMessage(sessionId, "agent", result.text, [], turn.id);
|
|
171
171
|
this.publish(sessionId, { type: "message", message });
|
|
172
172
|
})
|
|
173
173
|
.catch((error) => {
|
|
174
174
|
if (this.store.session(sessionId)?.status !== "open") {
|
|
175
|
-
this.store.finishTurn(turn.id, "interrupted", null, "Session ended mid-reply.");
|
|
175
|
+
this.store.finishTurn(sessionId, turn.id, "interrupted", null, "Session ended mid-reply.");
|
|
176
176
|
failed = true;
|
|
177
177
|
return;
|
|
178
178
|
}
|
|
179
179
|
const reason = String(error?.message ?? error).slice(0, 2000);
|
|
180
180
|
console.error(`[${sessionId}] turn ${turn.id} failed: ${reason}`);
|
|
181
|
-
this.store.finishTurn(turn.id, "failed", null, reason);
|
|
181
|
+
this.store.finishTurn(sessionId, turn.id, "failed", null, reason);
|
|
182
182
|
// Unanswered messages go back in the queue and ride along with the
|
|
183
183
|
// tester's next message, rather than retrying in a loop.
|
|
184
|
-
this.store.releaseTurn(turn.id);
|
|
184
|
+
this.store.releaseTurn(sessionId, turn.id);
|
|
185
185
|
failed = true;
|
|
186
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);
|
|
187
187
|
this.publish(sessionId, { type: "message", message });
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, createReadStream } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, createReadStream } from "node:fs";
|
|
3
3
|
import { join, extname, dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { loadConfig
|
|
7
|
-
import { Store } from "./db.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { Store, sessionDir } from "./db.js";
|
|
8
8
|
import { Runner, mediaTypeFor } from "./runner.js";
|
|
9
9
|
import { Conversations, Problem, MAX_TEXT, ownsUpload } from "./api.js";
|
|
10
10
|
import { RelayLink } from "./relay.js";
|
|
@@ -184,13 +184,14 @@ export async function serve() {
|
|
|
184
184
|
throw new Problem(429, "slow_down", "That's a lot of uploads very fast. Give it a few seconds.");
|
|
185
185
|
const data = await readBody(req, MAX_UPLOAD);
|
|
186
186
|
const name = `${session.id}-${randomBytes(9).toString("base64url")}${ext}`;
|
|
187
|
-
|
|
187
|
+
mkdirSync(sessionDir(session), { recursive: true });
|
|
188
|
+
writeFileSync(join(sessionDir(session), name), data);
|
|
188
189
|
return json(res, { name, url: `${scope.base}/uploads/${name}` });
|
|
189
190
|
}
|
|
190
191
|
const upload = rest.match(/^\/uploads\/([A-Za-z0-9_.-]+)$/);
|
|
191
192
|
if (upload && req.method === "GET") {
|
|
192
193
|
const name = upload[1];
|
|
193
|
-
const file = join(
|
|
194
|
+
const file = join(sessionDir(session), name);
|
|
194
195
|
if (!ownsUpload(session, name) || !existsSync(file))
|
|
195
196
|
return notFound(res);
|
|
196
197
|
res.writeHead(200, { "content-type": mediaTypeFor(name) ?? "application/octet-stream", "cache-control": "private, max-age=86400" });
|
package/dist/transcript.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { sessionDir } from "./db.js";
|
|
3
3
|
const agentName = (s) => (s.agent === "codex" ? "Codex" : "Claude");
|
|
4
4
|
const time = (iso) => new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
5
5
|
// Which model(s) answered, and on what login, from the recorded turns.
|
|
@@ -20,9 +20,20 @@ export function interviewer(store, session) {
|
|
|
20
20
|
// it marks everything shown as read.
|
|
21
21
|
export function transcript(store, session, link, { onlyNew = false } = {}) {
|
|
22
22
|
const messages = store.messages(session.id, onlyNew ? session.read_through : 0);
|
|
23
|
+
const text = render(store, session, messages, { link, onlyNew, image: (name) => `Screenshot: ${join(sessionDir(session), name)}` });
|
|
24
|
+
if (messages.length)
|
|
25
|
+
store.markRead(session.id, messages.at(-1).id);
|
|
26
|
+
return text;
|
|
27
|
+
}
|
|
28
|
+
// The whole session for <project>/.parley/<id>/transcript.md, beside its
|
|
29
|
+
// screenshots. Reading it doesn't mark anything read.
|
|
30
|
+
export function record(store, session) {
|
|
31
|
+
return render(store, session, store.messages(session.id), { link: null, onlyNew: false, image: (name) => `` });
|
|
32
|
+
}
|
|
33
|
+
function render(store, session, messages, { link, onlyNew, image }) {
|
|
23
34
|
const stats = store.stats(session.id);
|
|
24
35
|
const lines = [];
|
|
25
|
-
lines.push(`# Feedback from ${session.tester} on ${session.project_name}${onlyNew ? " (new since you last read)" : ""}`, "", `- Session: ${session.id}, ${session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}, link no longer works`}`, `- Interviewer: ${interviewer(store, session)}, started ${time(session.created_at)}`, `- Project: ${session.project_dir}`, ...(session.status === "open" ? [`- Tester link: ${link}`] : []), `- Messages: ${stats.tester_messages} from ${session.tester}, ${stats.agent_messages} from ${agentName(session)}`, `- Replies: ${stats.replies} of ${session.reply_limit}${spend(stats.cost_usd)}`, "");
|
|
36
|
+
lines.push(`# Feedback from ${session.tester} on ${session.project_name}${onlyNew ? " (new since you last read)" : ""}`, "", `- Session: ${session.id}, ${session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}, link no longer works`}`, `- Interviewer: ${interviewer(store, session)}, started ${time(session.created_at)}`, `- Project: ${session.project_dir}`, `- Files: ${sessionDir(session)}`, ...(session.status === "open" && link ? [`- Tester link: ${link}`] : []), `- Messages: ${stats.tester_messages} from ${session.tester}, ${stats.agent_messages} from ${agentName(session)}`, `- Replies: ${stats.replies} of ${session.reply_limit}${spend(stats.cost_usd)}`, "");
|
|
26
37
|
if (!onlyNew)
|
|
27
38
|
lines.push("## Brief", "", session.brief.trim(), "", "## Conversation", "");
|
|
28
39
|
if (!messages.length)
|
|
@@ -30,12 +41,10 @@ export function transcript(store, session, link, { onlyNew = false } = {}) {
|
|
|
30
41
|
for (const m of messages) {
|
|
31
42
|
const who = m.role === "tester" ? session.tester : m.role === "agent" ? agentName(session) : "Parley (error shown to tester)";
|
|
32
43
|
lines.push(`### ${who} · ${time(m.created_at)} · #${m.id}`, "", m.body.trim());
|
|
33
|
-
for (const
|
|
34
|
-
lines.push("",
|
|
44
|
+
for (const name of m.images)
|
|
45
|
+
lines.push("", image(name));
|
|
35
46
|
lines.push("");
|
|
36
47
|
}
|
|
37
|
-
if (messages.length)
|
|
38
|
-
store.markRead(session.id, messages.at(-1).id);
|
|
39
48
|
return lines.join("\n");
|
|
40
49
|
}
|
|
41
50
|
const spend = (usd) => (usd ? `, about $${usd.toFixed(2)} at API prices` : "");
|
package/package.json
CHANGED