@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.
- package/README.md +26 -9
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +279 -32
- 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/conversations.js +10 -0
- 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 +99 -11
- package/dist/db.js +87 -0
- package/dist/extensions/index.js +37 -0
- package/dist/extensions/web/anthropic.js +118 -0
- package/dist/extensions/web/artifacts.js +62 -0
- package/dist/extensions/web/content.js +130 -0
- package/dist/extensions/web/http.js +106 -0
- package/dist/extensions/web/index.js +9 -0
- package/dist/extensions/web/json.js +5 -0
- package/dist/extensions/web/language.js +47 -0
- package/dist/extensions/web/openai.js +112 -0
- package/dist/extensions/web/provider.js +121 -0
- package/dist/extensions/web/tools.js +304 -0
- package/dist/limits.js +14 -0
- package/dist/main.js +76 -10
- package/dist/paths.js +21 -1
- package/dist/settings.js +112 -13
- package/dist/tasks/agent.js +18 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/definitions.js +56 -12
- package/dist/tasks/execution.js +5 -1
- package/dist/tasks/groups.js +4 -4
- package/dist/tasks/messages.js +4 -2
- package/dist/tasks/runs.js +2 -2
- package/dist/tasks/service.js +16 -6
- package/dist/tasks/tool.js +0 -12
- 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 +175 -22
- package/dist/web/providers.js +16 -0
- package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
- package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
- package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +33 -9
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +170 -52
- package/dist/web/session-state.js +57 -44
- package/dist/web/terminal.js +34 -4
- package/dist/web/types.js +5 -0
- 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-DmDJKOLH.js +0 -90
- 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 →
|
|
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:
|
|
47
|
-
|
|
48
|
-
Telegram bot tokens; chats are discovered
|
|
49
|
-
gated by the mention/bind rules you set),
|
|
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
|
-
- **
|
|
67
|
-
|
|
68
|
-
back to `models.json`.
|
|
69
|
-
|
|
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
|
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
|
+
}
|