@timqi/pier 0.0.9 → 0.0.16
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 -1
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +190 -31
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +72 -0
- package/dist/db.js +78 -0
- package/dist/extensions/index.js +5 -2
- package/dist/extensions/web/artifacts.js +7 -2
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +33 -8
- package/dist/limits.js +14 -0
- package/dist/main.js +47 -6
- package/dist/paths.js +6 -1
- package/dist/settings.js +44 -0
- package/dist/tasks/agent.js +23 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +60 -12
- package/dist/tasks/execution.js +9 -1
- package/dist/tasks/groups.js +8 -4
- package/dist/tasks/messages.js +10 -2
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +7 -2
- package/dist/tasks/service.js +22 -6
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/tool.js +0 -12
- package/dist/tasks/types.js +4 -0
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +165 -36
- package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
- package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
- package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +22 -7
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +145 -64
- package/dist/web/session-state.js +33 -51
- package/dist/web/types.js +5 -0
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
- package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A self-hosted workspace for coding agents. Pier puts a web workbench and your
|
|
4
4
|
IM channels in front of [Pi](https://github.com/earendil-works/pi) sessions:
|
|
5
|
-
you talk to the same agent from a browser, from Slack or
|
|
5
|
+
you talk to the same agent from a browser, from Slack, Telegram or Lark, steer a
|
|
6
6
|
running turn, schedule tasks, watch what every session is doing, and publish a
|
|
7
7
|
static page when something is worth showing.
|
|
8
8
|
|
|
@@ -89,6 +89,11 @@ Console → Settings is the normal setup path:
|
|
|
89
89
|
`web_search` on an authenticated Anthropic or OpenAI model, `web_fetch` on an
|
|
90
90
|
Anthropic one (OpenAI hosts no fetch tool), and no other key or service. The
|
|
91
91
|
page says which tools a switch adds and what each needs before you flip it.
|
|
92
|
+
Below it, the same tab switches on the command-line tools Pier manages —
|
|
93
|
+
`rtk`, `rg`, `fd`, `wt`, `jq`, or a tool of your own written as a
|
|
94
|
+
[ubix](https://github.com/timqi/ubix) block. A switch installs the binary
|
|
95
|
+
into `~/.pier/tools/bin`, which is first on the PATH every session, task and
|
|
96
|
+
terminal inherits, and a task you can read keeps them current.
|
|
92
97
|
|
|
93
98
|
On first credential access, Pier imports an existing `auth.json` into its sealed
|
|
94
99
|
store and renames the source to `auth.json.imported`. Literal provider keys left
|
|
@@ -111,6 +116,7 @@ pier restart # drain running work, then restart
|
|
|
111
116
|
pier reload # re-read channel config and recycle idle sessions
|
|
112
117
|
pier backup # snapshot the database before a manual update
|
|
113
118
|
pier update # latest release, then hard-stop/restart the service
|
|
119
|
+
pier tools sync # install/update the managed CLI tools by hand
|
|
114
120
|
```
|
|
115
121
|
|
|
116
122
|
`pier restart` refuses new work, waits up to five minutes for active turns and
|
package/dist/agent/events.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|