@timqi/pier 0.0.8 → 0.0.15

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.
Files changed (71) hide show
  1. package/README.md +26 -9
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +279 -32
  5. package/dist/boards/boards.js +65 -16
  6. package/dist/boards/pier.css +1 -1
  7. package/dist/channels/attach.js +87 -0
  8. package/dist/channels/control.js +2 -2
  9. package/dist/channels/conversations.js +10 -0
  10. package/dist/channels/lark-api.js +38 -0
  11. package/dist/channels/lark-outbound.js +11 -2
  12. package/dist/channels/slack-api.js +36 -0
  13. package/dist/channels/slack-outbound.js +12 -2
  14. package/dist/channels/slack-tool.js +49 -9
  15. package/dist/channels/telegram-api.js +21 -2
  16. package/dist/channels/telegram.js +23 -8
  17. package/dist/cli.js +34 -0
  18. package/dist/core/identity.js +18 -0
  19. package/dist/core/inbound-file.js +3 -1
  20. package/dist/core/reply.js +2 -1
  21. package/dist/core/router.js +99 -11
  22. package/dist/db.js +87 -0
  23. package/dist/extensions/index.js +37 -0
  24. package/dist/extensions/web/anthropic.js +118 -0
  25. package/dist/extensions/web/artifacts.js +62 -0
  26. package/dist/extensions/web/content.js +130 -0
  27. package/dist/extensions/web/http.js +106 -0
  28. package/dist/extensions/web/index.js +9 -0
  29. package/dist/extensions/web/json.js +5 -0
  30. package/dist/extensions/web/language.js +47 -0
  31. package/dist/extensions/web/openai.js +112 -0
  32. package/dist/extensions/web/provider.js +121 -0
  33. package/dist/extensions/web/tools.js +304 -0
  34. package/dist/limits.js +14 -0
  35. package/dist/main.js +76 -10
  36. package/dist/paths.js +21 -1
  37. package/dist/settings.js +112 -13
  38. package/dist/tasks/agent.js +18 -4
  39. package/dist/tasks/callbacks.js +20 -1
  40. package/dist/tasks/definitions.js +56 -12
  41. package/dist/tasks/execution.js +5 -1
  42. package/dist/tasks/groups.js +4 -4
  43. package/dist/tasks/messages.js +4 -2
  44. package/dist/tasks/runs.js +2 -2
  45. package/dist/tasks/service.js +16 -6
  46. package/dist/tasks/tool.js +0 -12
  47. package/dist/tools-task.js +155 -0
  48. package/dist/tools.js +875 -0
  49. package/dist/web/auth.js +5 -3
  50. package/dist/web/explorer.js +15 -2
  51. package/dist/web/files.js +1 -1
  52. package/dist/web/instance.js +175 -22
  53. package/dist/web/providers.js +16 -0
  54. package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
  55. package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
  56. package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
  57. package/dist/web/public/index.html +5 -8
  58. package/dist/web/public/sw.js +4 -0
  59. package/dist/web/push.js +33 -9
  60. package/dist/web/repos.js +75 -0
  61. package/dist/web/server.js +170 -52
  62. package/dist/web/session-state.js +57 -44
  63. package/dist/web/terminal.js +34 -4
  64. package/dist/web/types.js +5 -0
  65. package/package.json +1 -1
  66. package/skills/pier-boards/SKILL.md +23 -13
  67. package/skills/pier-help/SKILL.md +1 -1
  68. package/skills/pier-slack/SKILL.md +21 -1
  69. package/skills/pier-tasks/SKILL.md +2 -2
  70. package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
  71. package/dist/web/public/assets/index-gcSJ9QZ5.css +0 -2
package/README.md CHANGED
@@ -19,7 +19,7 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
19
19
  - Node 24 or newer (`node:sqlite` is used unflagged)
20
20
  - Linux: Python 3, `make` and a C/C++ compiler for `node-pty`
21
21
  - A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
22
- login from Console → Settings → Providers after signing in
22
+ login from Console → Settings → Models after signing in
23
23
  - A user-writable global npm prefix if `pier update` should update a service
24
24
  - Optional: the `sqlite3` CLI, for backups and password resets
25
25
 
@@ -43,10 +43,10 @@ is behind it — there is no default password and no unclaimed window. Lost it?
43
43
  Open `http://localhost:3141`, sign in, then:
44
44
 
45
45
  - **Console → Settings** — everything the instance is configured with, one
46
- tab per topic: Providers (API-key/OAuth logins), Models (the menu of
47
- favored models agents are advised with), Channels (Slack Socket-Mode or
48
- Telegram bot tokens; chats are discovered when the bot first sees traffic,
49
- gated by the mention/bind rules you set), Agent files (Pi configuration),
46
+ tab per topic: Models (API-key/OAuth logins, and the menu of favored models
47
+ agents are advised with), Agent (Pi configuration, skills, extensions),
48
+ Channels (Slack Socket-Mode or Telegram bot tokens; chats are discovered
49
+ when the bot first sees traffic, gated by the mention/bind rules you set),
50
50
  plus the public URL, password and master key
51
51
  - **New session** — pick a directory; that is where the agent's shell runs
52
52
 
@@ -61,17 +61,34 @@ an existing Pi setup:
61
61
  PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier serve
62
62
  ```
63
63
 
64
+ Pier exports that variable for the SDK, so anything it starts inherits it — a
65
+ shell in the Web Terminal included. A second Pier launched from there with its
66
+ own `PIER_HOME` derives its own agent directory rather than adopting the first
67
+ one's; setting `PI_CODING_AGENT_DIR` again on that command line still wins.
68
+
64
69
  Console → Settings is the normal setup path:
65
70
 
66
- - **Providers** configures built-in or custom endpoints and API-key/OAuth login.
67
- Stored credentials are sealed in Pier's SQLite database; they are not written
68
- back to `models.json`.
69
- - **Agent files** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
71
+ - **Models** configures built-in or custom endpoints and API-key/OAuth login,
72
+ then pins the few models this deployment favors. Stored credentials are
73
+ sealed in Pier's SQLite database; they are not written back to `models.json`.
74
+ **Test** sends one real request on a model you pick and shows both halves of
75
+ it — the body as the provider received it, and what came back — so a wrong
76
+ base URL, a revoked key, a gateway rewriting the request or a model the
77
+ endpoint never had says so here instead of in a session.
78
+ - **Agent** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
70
79
  `models.json` structure in the Pi agent directory — globally, or per project
71
80
  scope, where it also shows that project's `.pi/skills` and `.pi/extensions`
72
81
  resources. Changes apply when a session next opens; saving here recycles the
73
82
  idle ones for you, and **Settings → Instance → Reload** does it for files
74
83
  something else changed — an agent, or an editor on the box.
84
+ The same tab lists the extensions Pier ships with, under Global — they live
85
+ inside the package, so there is nothing to install and an update never
86
+ touches your own `extensions` directory, and if an extension of yours
87
+ already registers the same tool, Pier's copy stands down. `web` is the one
88
+ shipped today: the public web through the provider's own hosted web tools —
89
+ `web_search` on an authenticated Anthropic or OpenAI model, `web_fetch` on an
90
+ Anthropic one (OpenAI hosts no fetch tool), and no other key or service. The
91
+ page says which tools a switch adds and what each needs before you flip it.
75
92
 
76
93
  On first credential access, Pier imports an existing `auth.json` into its sealed
77
94
  store and renames the source to `auth.json.imported`. Literal provider keys left
@@ -2,7 +2,7 @@
2
2
  // purpose: no @earendil-works/pi-* imports, so it stays unit-testable without Pi
3
3
  // and Pi types never leak past the seam. The golden-table test in
4
4
  // events.test.ts is the mapping's spec; extend types.ts before adding events.
5
- import { MAX_STEP_OUTPUT } from "../core/types.js";
5
+ import { isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
6
6
  export function textOf(content) {
7
7
  if (typeof content === "string")
8
8
  return content;
@@ -19,21 +19,44 @@ function systemOrigin(message) {
19
19
  const value = message.details;
20
20
  if (!value || typeof value !== "object")
21
21
  return null;
22
- const origin = value;
22
+ const { source: raw, ...origin } = value;
23
23
  if (typeof origin.taskId !== "string" ||
24
24
  typeof origin.runId !== "string" ||
25
25
  (origin.sourceSessionId !== null && typeof origin.sourceSessionId !== "string"))
26
26
  return null;
27
+ // Rebuilt around a checked `source` rather than cast through it: a
28
+ // half-valid one drawn by the card is an `undefined` in a chip, which reads
29
+ // as a bug in the card rather than as bad metadata.
30
+ const source = inputSource(raw);
31
+ const shape = { ...origin, ...(source ? { source } : {}) };
27
32
  if (origin.kind === "task-delegation" || origin.kind === "task-callback") {
28
- return origin;
33
+ return shape;
29
34
  }
30
35
  if (origin.kind === "task-message" &&
31
36
  typeof origin.messageId === "string" &&
32
37
  (origin.messageKind === "steer" || origin.messageKind === "follow_up" ||
33
38
  origin.messageKind === "progress" || origin.messageKind === "decision" || origin.messageKind === "reply"))
34
- return origin;
39
+ return shape;
35
40
  return null;
36
41
  }
42
+ /** What produced a system input, as read back off disk: the name is the whole
43
+ * point of it, the model and the effort are each kept only if they are the
44
+ * shape they claim to be (core/types.ts). */
45
+ function inputSource(value) {
46
+ if (!value || typeof value !== "object")
47
+ return undefined;
48
+ const { taskName, model, thinking } = value;
49
+ if (typeof taskName !== "string")
50
+ return undefined;
51
+ const ref = (model ?? {});
52
+ return {
53
+ taskName,
54
+ ...(typeof ref.provider === "string" && typeof ref.id === "string"
55
+ ? { model: { provider: ref.provider, id: ref.id } }
56
+ : {}),
57
+ ...(isThinkingLevel(thinking) ? { thinking } : {}),
58
+ };
59
+ }
37
60
  function lastAssistant(messages) {
38
61
  if (!messages)
39
62
  return undefined;
@@ -86,12 +109,17 @@ export function toChatTurns(messages) {
86
109
  const turns = [];
87
110
  let steps = []; // activity seen since the last emitted turn
88
111
  const pendingTools = new Map();
89
- const flush = (role, text, meta, origin) => {
112
+ const flush = (role, text, meta, origin, at) => {
90
113
  const turn = { role, text };
91
114
  if (meta)
92
115
  turn.meta = meta;
93
116
  if (origin)
94
117
  turn.origin = origin;
118
+ // An assistant turn already says when it finished; these two would have no
119
+ // time at all after a reload, which is the one place the live stream's own
120
+ // stamp is gone.
121
+ if (at !== undefined && role !== "assistant")
122
+ turn.at = at;
95
123
  if (steps.length) {
96
124
  turn.steps = steps;
97
125
  steps = [];
@@ -116,7 +144,7 @@ export function toChatTurns(messages) {
116
144
  if (origin) {
117
145
  const text = textOf(m.content);
118
146
  if (text)
119
- flush("system", text, undefined, origin);
147
+ flush("system", text, undefined, origin, m.timestamp);
120
148
  continue;
121
149
  }
122
150
  if (m.role !== "user" && m.role !== "assistant")
@@ -143,7 +171,7 @@ export function toChatTurns(messages) {
143
171
  // step-only assistant messages keep buffering activity
144
172
  if (!text)
145
173
  continue;
146
- flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined);
174
+ flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined, undefined, m.timestamp);
147
175
  }
148
176
  // Activity with no answer after it (aborted run) still belongs on the page.
149
177
  if (steps.length)
@@ -207,6 +235,24 @@ export function toSessionEvents(e) {
207
235
  followUp: [...(e.followUp ?? [])],
208
236
  },
209
237
  ];
238
+ case "compaction_end": {
239
+ // The only trace compaction leaves anywhere: Pi replaces the summarized
240
+ // entries with a `compactionSummary` message, which `toChatTurns` above
241
+ // renders nothing for — so without this event the button's effect is
242
+ // invisible and the automatic one is invisible twice over (§5b).
243
+ const r = e.result;
244
+ if (r && typeof r.tokensBefore === "number") {
245
+ return [{
246
+ type: "context-compacted",
247
+ before: r.tokensBefore,
248
+ after: r.estimatedTokensAfter ?? r.tokensBefore,
249
+ }];
250
+ }
251
+ // No result means the context was *not* shrunk — cancelled, or the
252
+ // summarization call failed. A manual compact reports through its route
253
+ // as well; an automatic one has no route, and this is all it has.
254
+ return [{ type: "error", message: e.errorMessage ?? "compaction cancelled" }];
255
+ }
210
256
  case "tool_execution_end":
211
257
  return [
212
258
  {
@@ -0,0 +1,253 @@
1
+ // Which sessions exist on disk, and what to call them — without reading a
2
+ // transcript that has not changed since the last scan.
3
+ //
4
+ // Pi's SessionManager.listAll() parses every line of every session file to
5
+ // answer that: ~250ms for 84 sessions of 30MB here, growing with the total
6
+ // bytes ever written rather than with the number of sessions. Every surface
7
+ // asks (the rail, Activity, a task lookup, every reconnect), so the scan sits
8
+ // on the interactive path and gets slower for the life of the instance. But a
9
+ // transcript is append-only and the facts a listing needs are all derived from
10
+ // bytes already read, so each byte is read once: rows live in pier.db, keyed by
11
+ // path and validated by (size, mtime), and a file that grew is picked up at the
12
+ // byte the last scan stopped on.
13
+ //
14
+ // Reading Pi's on-disk entries is the cost of that; agent/ is where the
15
+ // knowledge of Pi's formats already lives, and Pi's own reader is not
16
+ // incremental.
17
+ import { createReadStream, promises as fs } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { pierDb } from "../db.js";
20
+ import { SESSION_TITLE_MAX } from "../limits.js";
21
+ import { logger } from "../log.js";
22
+ import { defaultAgentDir } from "./config.js";
23
+ import { textOf } from "./events.js";
24
+ const log = logger("agent");
25
+ /** How many of the newest sessions a cross-check reads: enough that a format
26
+ * change surfaces within a boot or two, few enough to cost nothing. */
27
+ const AUDIT_SAMPLE = 5;
28
+ /** The name a session was given, else its first message. Two arguments rather
29
+ * than a row, because the stored row and the parsed one spell the second one
30
+ * differently and an object would silently accept either. */
31
+ const titleOf = (name, first) => name || first || undefined;
32
+ const str = (v) => (typeof v === "string" ? v : undefined);
33
+ /**
34
+ * One entry folded into what a listing needs. Three answers, not two:
35
+ * `undefined` is "no header yet" (blank and unparseable lines before it are
36
+ * skipped, as Pi's own reader does), `null` is "not a session file" — a first
37
+ * entry that is not a session header — and anything else is the state so far.
38
+ */
39
+ function fold(acc, line) {
40
+ // The bulk of a transcript is message entries, each longer than this file.
41
+ // Once the first user message is in hand only a rename still matters, and a
42
+ // substring test costs a fraction of parsing one of them.
43
+ if (acc?.first && !line.includes('"session_info"'))
44
+ return acc;
45
+ let entry;
46
+ try {
47
+ const value = JSON.parse(line);
48
+ if (value && typeof value === "object")
49
+ entry = value;
50
+ }
51
+ catch {
52
+ return acc ?? undefined;
53
+ }
54
+ if (!acc) {
55
+ if (!entry)
56
+ return undefined;
57
+ const id = str(entry.id);
58
+ const timestamp = str(entry.timestamp);
59
+ if (entry.type !== "session" || !id || !timestamp)
60
+ return null;
61
+ return { id, cwd: str(entry.cwd) ?? "", created: Date.parse(timestamp), at: 0 };
62
+ }
63
+ if (entry?.type === "session_info")
64
+ return { ...acc, name: str(entry.name)?.trim() || undefined };
65
+ if (entry?.type === "message" && !acc.first) {
66
+ const message = entry.message;
67
+ if (message?.role !== "user")
68
+ return acc;
69
+ const text = textOf(message.content).trim();
70
+ return text ? { ...acc, first: text.slice(0, SESSION_TITLE_MAX) } : acc;
71
+ }
72
+ return acc;
73
+ }
74
+ /** The listing Pier runs on: Pi's session directory, remembered in pier.db. */
75
+ export class IndexedListing {
76
+ dir;
77
+ #db;
78
+ constructor(dir = join(defaultAgentDir(), "sessions"), db) {
79
+ this.dir = dir;
80
+ this.#db = db;
81
+ }
82
+ /** Opened on the first scan rather than in the constructor: building an
83
+ * agent factory must not open the instance database — tests build bare ones
84
+ * and never list. */
85
+ #store() {
86
+ return (this.#db ??= pierDb());
87
+ }
88
+ async scan() {
89
+ const db = this.#store();
90
+ const known = new Map(db.prepare("SELECT * FROM session_index").all().map((row) => [row.path, row]));
91
+ const records = [];
92
+ const write = [];
93
+ for (const file of await this.#files()) {
94
+ const row = known.get(file.path);
95
+ known.delete(file.path); // what is left over is no longer on disk
96
+ if (row && row.size === file.size && row.mtime === file.modified) {
97
+ const title = titleOf(row.name, row.first_message);
98
+ records.push({
99
+ id: row.id,
100
+ path: row.path,
101
+ cwd: row.cwd,
102
+ created: row.created_at,
103
+ modified: row.mtime,
104
+ ...(title ? { title } : {}),
105
+ });
106
+ continue;
107
+ }
108
+ // Only a file that *grew* can be resumed mid-way. Anything else is read
109
+ // whole — new, truncated, or rewritten. Rewritten includes the same
110
+ // length with a different mtime, which resuming would answer with the
111
+ // old derived data stamped with the new mtime: a row that then matches on
112
+ // every later scan and is never corrected.
113
+ const parsed = await this.#read(file.path, row && file.size > row.size
114
+ ? {
115
+ id: row.id,
116
+ cwd: row.cwd,
117
+ created: row.created_at,
118
+ ...(row.name === null ? {} : { name: row.name }),
119
+ ...(row.first_message === null ? {} : { first: row.first_message }),
120
+ at: row.parsed_bytes,
121
+ }
122
+ : undefined);
123
+ if (!parsed)
124
+ continue; // not a session file, or unreadable
125
+ write.push({
126
+ path: file.path,
127
+ id: parsed.id,
128
+ cwd: parsed.cwd,
129
+ created_at: parsed.created,
130
+ name: parsed.name ?? null,
131
+ first_message: parsed.first ?? null,
132
+ size: file.size,
133
+ mtime: file.modified,
134
+ parsed_bytes: parsed.at,
135
+ });
136
+ const title = titleOf(parsed.name, parsed.first);
137
+ records.push({
138
+ id: parsed.id,
139
+ path: file.path,
140
+ cwd: parsed.cwd,
141
+ created: parsed.created,
142
+ modified: file.modified,
143
+ ...(title ? { title } : {}),
144
+ });
145
+ }
146
+ this.#save(write, [...known.keys()]);
147
+ return records.sort((a, b) => b.modified - a.modified);
148
+ }
149
+ /**
150
+ * The reason this file is allowed to exist: it parses Pi's transcripts
151
+ * itself, and nothing but a comparison keeps it honest when that format
152
+ * moves. Reads the newest few both ways; a disagreement is logged with both
153
+ * answers and the index row is dropped, so the next scan reads that file
154
+ * whole rather than trusting what this one derived. Returns how many
155
+ * disagreed — off the interactive path, never blocking a boot.
156
+ */
157
+ async audit(native) {
158
+ const sample = (await this.scan()).slice(0, AUDIT_SAMPLE);
159
+ if (!sample.length)
160
+ return 0;
161
+ const theirs = new Map((await native()).map((info) => [info.id, info]));
162
+ const stale = [];
163
+ for (const mine of sample) {
164
+ const info = theirs.get(mine.id);
165
+ const seen = { cwd: mine.cwd, created: mine.created, title: mine.title };
166
+ const want = info && {
167
+ cwd: info.cwd,
168
+ created: info.created.getTime(),
169
+ title: info.name ?? info.firstMessage?.slice(0, SESSION_TITLE_MAX),
170
+ };
171
+ if (want && want.cwd === seen.cwd && want.created === seen.created &&
172
+ want.title === seen.title)
173
+ continue;
174
+ log.warn(`session index disagrees with Pi about ${mine.id}: index ${JSON.stringify(seen)}, Pi ${JSON.stringify(want ?? "no such session")} — dropping ${mine.path}`);
175
+ stale.push(mine.path);
176
+ }
177
+ this.#save([], stale);
178
+ return stale.length;
179
+ }
180
+ /** Every session file with its size and mtime — the only syscalls a scan
181
+ * where nothing changed makes. */
182
+ async #files() {
183
+ const entries = await fs.readdir(this.dir, { withFileTypes: true }).catch(() => []);
184
+ const dirs = entries.filter((e) => e.isDirectory() || e.isSymbolicLink());
185
+ const found = await Promise.all(dirs.map(async (dir) => {
186
+ const at = join(this.dir, dir.name);
187
+ const names = await fs.readdir(at).catch(() => []);
188
+ return Promise.all(names.filter((n) => n.endsWith(".jsonl")).map(async (name) => {
189
+ const path = join(at, name);
190
+ const stat = await fs.stat(path).catch(() => null);
191
+ return stat?.isFile()
192
+ ? { path, size: stat.size, modified: Math.round(stat.mtimeMs) }
193
+ : null;
194
+ }));
195
+ }));
196
+ return found.flat().filter((f) => !!f);
197
+ }
198
+ /** From `from.at`, or from the start when there is nothing to resume. */
199
+ async #read(path, from) {
200
+ let acc = from;
201
+ let at = from?.at ?? 0;
202
+ let buffer = "";
203
+ try {
204
+ for await (const chunk of createReadStream(path, { encoding: "utf8", start: at })) {
205
+ buffer += chunk;
206
+ for (let nl = buffer.indexOf("\n"); nl !== -1; nl = buffer.indexOf("\n")) {
207
+ const line = buffer.slice(0, nl);
208
+ buffer = buffer.slice(nl + 1);
209
+ at += Buffer.byteLength(line) + 1;
210
+ const next = fold(acc, line);
211
+ if (next === null)
212
+ return null;
213
+ acc = next;
214
+ }
215
+ }
216
+ }
217
+ catch (err) {
218
+ // A file that cannot be read is left out of this listing and out of the
219
+ // index, so the next scan tries it again rather than remembering a gap.
220
+ log.warn(`session file ${path} could not be read`, err);
221
+ return null;
222
+ }
223
+ return acc ? { ...acc, at } : null;
224
+ }
225
+ /** One transaction, after all reading: a half-written index would hand the
226
+ * next scan a size it never parsed to. */
227
+ #save(rows, gone) {
228
+ if (!rows.length && !gone.length)
229
+ return;
230
+ const db = this.#store();
231
+ const upsert = db.prepare(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
232
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
233
+ ON CONFLICT(path) DO UPDATE SET
234
+ id = excluded.id, cwd = excluded.cwd, created_at = excluded.created_at,
235
+ name = excluded.name, first_message = excluded.first_message,
236
+ size = excluded.size, mtime = excluded.mtime,
237
+ parsed_bytes = excluded.parsed_bytes`);
238
+ const drop = db.prepare("DELETE FROM session_index WHERE path = ?");
239
+ db.exec("BEGIN");
240
+ try {
241
+ for (const r of rows) {
242
+ upsert.run(r.path, r.id, r.cwd, r.created_at, r.name, r.first_message, r.size, r.mtime, r.parsed_bytes);
243
+ }
244
+ for (const path of gone)
245
+ drop.run(path);
246
+ db.exec("COMMIT");
247
+ }
248
+ catch (err) {
249
+ db.exec("ROLLBACK");
250
+ throw err;
251
+ }
252
+ }
253
+ }