@alphacoder-v0/pi-loops 0.22.8 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +114 -78
  4. package/README.zh-CN.md +62 -41
  5. package/docs/cli.md +352 -91
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +404 -61
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +41 -28
  17. package/docs/web-ui-parity.md +702 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/browser-open.ts +169 -0
  32. package/src/cli-entry.mjs +12 -3
  33. package/src/cli.ts +960 -143
  34. package/src/config.ts +53 -5
  35. package/src/conversation-rows.ts +469 -0
  36. package/src/danger.ts +227 -6
  37. package/src/engine.ts +1240 -0
  38. package/src/goal.ts +71 -0
  39. package/src/held-read.ts +27 -0
  40. package/src/hooks.ts +49 -18
  41. package/src/host-control-channel.ts +455 -40
  42. package/src/host-control.ts +602 -111
  43. package/src/host-entry.mjs +5 -3
  44. package/src/host-runtime.ts +455 -42
  45. package/src/host.ts +659 -123
  46. package/src/inbox-card.ts +278 -0
  47. package/src/inbox.ts +139 -62
  48. package/src/job-edit.ts +7 -1
  49. package/src/job-health.ts +14 -2
  50. package/src/job-lines.ts +201 -13
  51. package/src/job-owner.ts +74 -13
  52. package/src/job-tune.ts +110 -0
  53. package/src/lease.ts +537 -0
  54. package/src/lock.ts +7 -2
  55. package/src/mcp-pool.ts +66 -14
  56. package/src/mcp.ts +1 -1
  57. package/src/open-file.ts +54 -0
  58. package/src/page-children.ts +1507 -0
  59. package/src/page-devices.ts +192 -0
  60. package/src/page-server.ts +3150 -0
  61. package/src/path-precheck.ts +215 -0
  62. package/src/paths.ts +24 -0
  63. package/src/pi-floor.ts +10 -0
  64. package/src/pi-loops.ts +1370 -473
  65. package/src/pi-resolver.d.mts +1 -0
  66. package/src/pi-resolver.mjs +110 -10
  67. package/src/presence.ts +12 -63
  68. package/src/protocol.ts +10 -1
  69. package/src/recipe.ts +43 -1
  70. package/src/requests.ts +354 -0
  71. package/src/rule-lines.ts +68 -0
  72. package/src/run-card.ts +105 -0
  73. package/src/runner.ts +18 -1
  74. package/src/schedule.ts +1 -2
  75. package/src/scheduler.ts +595 -245
  76. package/src/sdk-runner.ts +321 -27
  77. package/src/seen.ts +278 -0
  78. package/src/session-head.ts +21 -0
  79. package/src/share.ts +12 -2
  80. package/src/snapshot.ts +3 -2
  81. package/src/store.ts +81 -8
  82. package/src/tools.ts +196 -65
  83. package/src/trigger-runtime.ts +164 -127
  84. package/src/triggers.ts +39 -7
  85. package/src/trust.ts +46 -2
  86. package/src/ui-prefs.ts +22 -7
  87. package/src/watch-plan.ts +174 -0
  88. package/src/web.d.mts +3 -0
  89. package/src/web.mjs +2261 -2340
  90. package/src/window-executor.ts +459 -0
  91. package/src/window-guard.ts +158 -0
  92. package/docs/agents/issue-tracker.md +0 -34
package/src/config.ts CHANGED
@@ -6,16 +6,22 @@
6
6
  * poll_interval_secs = 600
7
7
  * run_timeout_secs = 900 # cap on a check/action sub-agent
8
8
  * [cron]
9
- * catch_up = true # fire the tick a loop missed while no pi was open (false wins over --catchup)
9
+ * catch_up = true # fire the tick a loop missed while the host was not running (false wins over --catchup)
10
10
  * max_concurrent_runs = 3 # sub-agents in flight at once — loop runs and trigger checks share it
11
11
  * [hooks]
12
12
  * mode = "sync" # sync = awaited inline; async = queued off the turn
13
13
  * [host]
14
- * auto = true # when the last pi quits, a headless host keeps loops and triggers running
14
+ * auto = true # a pi window starts the always-on host when there is automation to run
15
15
  * [limits]
16
16
  * daily_budget_usd = 5.0 # stop dispatching once today's automation has cost this much (0 = no cap)
17
17
  * [danger]
18
18
  * allow = ["rm -rf /var/cache/x"] # command prefixes an unattended run may use anyway
19
+ * [engine]
20
+ * window_guard = true # a scheduled turn in an open window asks before a dangerous command or an undeclared tool
21
+ * [page]
22
+ * port = 4173 # the browser page's port; unset, the default loops directory gets 4173 and any other a free port
23
+ * host = "127.0.0.1" # anything but loopback puts the page on the local network, unencrypted
24
+ * allow_host = ["box.example"] # names the page answers to besides loopback and a tailnet's
19
25
  */
20
26
  import * as fs from "node:fs";
21
27
  import * as path from "node:path";
@@ -29,17 +35,39 @@ export interface LoopsConfig {
29
35
  cronCatchUp: boolean;
30
36
  maxConcurrentRuns: number;
31
37
  hooksMode: "sync" | "async";
32
- /** Start the headless host when the last interactive pi quits with automation configured. */
38
+ /** Let a pi window start the always-on host when there is a loop, a rule or a push server to run. */
33
39
  hostAuto: boolean;
34
40
  /** Stop dispatching once automation has spent this much today (local time). 0 = no cap. */
35
41
  dailyBudgetUsd: number;
36
42
  /** Command prefixes an unattended run may use despite the dangerous-command gate. */
37
43
  allowCommands: string[];
44
+ /** Cap a scheduled turn in an open window as the background caps one: ask, and refuse unanswered (design O1). */
45
+ windowGuard: boolean;
46
+ /** The browser page: its port, the address it binds, the extra names it answers to. */
47
+ pagePort: number;
48
+ /** Whether `[page] port` was set in the file: then every loops directory uses it (`pageBindPort`). */
49
+ pagePortSet: boolean;
50
+ pageHost: string;
51
+ pageAllowHosts: string[];
38
52
  errors: string[];
39
53
  }
40
54
 
41
55
  export const DEFAULT_TRIGGER_RUN_TIMEOUT_SECS = 15 * 60;
42
56
  export const DEFAULT_MAX_CONCURRENT_RUNS = 3;
57
+ export const DEFAULT_PAGE_PORT = 4173;
58
+
59
+ /**
60
+ * The port the browser page binds. `PI_LOOPS_PAGE_PORT` (0 to 65535) wins; then `[page] port` when the
61
+ * file sets it, for any loops directory; unset, the default loops directory gets the fixed port a
62
+ * bookmark can rely on and any other directory (a test run, a second setup) a free one, so two never
63
+ * fight over one.
64
+ */
65
+ export function pageBindPort(config: Pick<LoopsConfig, "pagePort" | "pagePortSet">, where: { defaultDir: boolean; env?: string }): number {
66
+ const env = Number(where.env);
67
+ if (where.env && Number.isInteger(env) && env >= 0 && env <= 65535) return env;
68
+ if (config.pagePortSet) return config.pagePort;
69
+ return where.defaultDir ? config.pagePort : 0;
70
+ }
43
71
 
44
72
  /**
45
73
  * An on/off environment override, read the way a person would write one: `PI_<NAME>=1` or `=true`,
@@ -52,7 +80,7 @@ export function envFlag(name: string): boolean {
52
80
  }
53
81
 
54
82
  export function loadConfig(dir: string): LoopsConfig {
55
- const cfg: LoopsConfig = { allowProjectHooks: false, triggerPollIntervalSecs: DEFAULT_TRIGGER_POLL_INTERVAL_SECS, triggerRunTimeoutMs: DEFAULT_TRIGGER_RUN_TIMEOUT_SECS * 1000, cronCatchUp: true, maxConcurrentRuns: DEFAULT_MAX_CONCURRENT_RUNS, hooksMode: "sync", hostAuto: true, dailyBudgetUsd: 0, allowCommands: [], errors: [] };
83
+ const cfg: LoopsConfig = { allowProjectHooks: false, triggerPollIntervalSecs: DEFAULT_TRIGGER_POLL_INTERVAL_SECS, triggerRunTimeoutMs: DEFAULT_TRIGGER_RUN_TIMEOUT_SECS * 1000, cronCatchUp: true, maxConcurrentRuns: DEFAULT_MAX_CONCURRENT_RUNS, hooksMode: "sync", hostAuto: true, dailyBudgetUsd: 0, allowCommands: [], windowGuard: true, pagePort: DEFAULT_PAGE_PORT, pagePortSet: false, pageHost: "127.0.0.1", pageAllowHosts: [], errors: [] };
56
84
  const file = path.join(dir, "config.toml");
57
85
  const positiveInt = (section: string, key: string, v: unknown, apply: (n: number) => void) => {
58
86
  if (v === undefined) return;
@@ -88,7 +116,27 @@ export function loadConfig(dir: string): LoopsConfig {
88
116
  const ho = doc.host as any;
89
117
  if (ho && ho.auto !== undefined) {
90
118
  if (typeof ho.auto === "boolean") cfg.hostAuto = ho.auto;
91
- else cfg.errors.push(`host: ignoring invalid auto in ${file}: must be true or false`);
119
+ else cfg.errors.push(`[host] auto: ignoring an invalid value in ${file}: must be true or false`);
120
+ }
121
+ const en = doc.engine as any;
122
+ if (en && en.window_guard !== undefined) {
123
+ if (typeof en.window_guard === "boolean") cfg.windowGuard = en.window_guard;
124
+ else cfg.errors.push(`[engine] window_guard: ignoring an invalid value in ${file}: must be true or false`);
125
+ }
126
+ const pg = doc.page as any;
127
+ if (pg && pg.port !== undefined) {
128
+ if (Number.isInteger(pg.port) && pg.port >= 1 && pg.port <= 65535) {
129
+ cfg.pagePort = pg.port;
130
+ cfg.pagePortSet = true;
131
+ } else cfg.errors.push(`[page] port: ignoring an invalid value in ${file}: must be a port number from 1 to 65535`);
132
+ }
133
+ if (pg && pg.host !== undefined) {
134
+ if (typeof pg.host === "string" && pg.host.trim()) cfg.pageHost = pg.host.trim();
135
+ else cfg.errors.push(`[page] host: ignoring an invalid value in ${file}: must be an address to bind, such as "127.0.0.1"`);
136
+ }
137
+ if (pg && pg.allow_host !== undefined) {
138
+ if (Array.isArray(pg.allow_host) && pg.allow_host.every((x: unknown) => typeof x === "string")) cfg.pageAllowHosts = pg.allow_host as string[];
139
+ else cfg.errors.push(`[page] allow_host: ignoring an invalid value in ${file}: must be a list of host names`);
92
140
  }
93
141
  const h = doc.hooks as any;
94
142
  if (h && h.mode !== undefined) {
@@ -0,0 +1,469 @@
1
+ /**
2
+ * The page's conversation list, as a file read: pi's session files under its sessions root, the
3
+ * lease of each conversation, the request rows queued for it and the inbox's open decisions. Nothing
4
+ * here starts a pi or writes a file, so showing the list costs no child of the page's six.
5
+ *
6
+ * pi keeps a project's conversations in `<sessions root>/<encoded cwd>/*.jsonl`. The encoding is
7
+ * pi's, so a directory is never decoded here: a project is the `cwd` in the header of its newest
8
+ * file, and every lookup from HTTP is an exact match against those, never a path that is read.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { holderDelivers, type LeaseHolder, type SessionLease, sessionFileName } from "./lease.ts";
14
+ import { stamp } from "./schedule.ts";
15
+ import { previewText, readSessionHead, type SessionHead } from "./session-head.ts";
16
+
17
+ /** Who has a conversation open, in the list's words: a terminal, the page, pi-loops in the background, or nobody. */
18
+ export type HolderKind = "terminal" | "page" | "background" | "none";
19
+
20
+ export interface ConversationRow {
21
+ sessionId: string;
22
+ cwd: string;
23
+ /** `/name`, else the first thing said; undefined for a conversation with neither yet. */
24
+ title?: string;
25
+ /** The file's mtime, as a local stamp (`stamp()`). */
26
+ lastActivityAt: string;
27
+ holder: HolderKind;
28
+ /** Whether that holder answers what is sent to it (`holderDelivers`): a `pi -p` run or a window standing aside does not. */
29
+ delivers: boolean;
30
+ /** The page has a pi for it now. */
31
+ open: boolean;
32
+ /** Open decisions (new checkpoints) filed by loops this conversation created. */
33
+ needsYou: number;
34
+ /** Input rows waiting in `requests/<id>.jsonl`. */
35
+ queued: number;
36
+ }
37
+
38
+ export interface ProjectRow {
39
+ cwd: string;
40
+ /** The cwd with the home directory written `~`: unique, as the cwd is. */
41
+ label: string;
42
+ /** The newest conversation's mtime; undefined for a project known only by a lease or a job. */
43
+ lastActivityAt?: string;
44
+ /** Session files in it. */
45
+ conversations: number;
46
+ }
47
+
48
+ export type HeadReader = (file: string) => SessionHead | undefined;
49
+ /** A session file's header id, from its first line alone. */
50
+ export type IdReader = (file: string) => string | undefined;
51
+
52
+ /** What the rows need besides pi's files: each is read once per answer. */
53
+ export interface RowReaders {
54
+ lease(sessionId: string): Partial<Pick<SessionLease, "holder" | "cwd">> | undefined;
55
+ alive(holder: LeaseHolder): boolean;
56
+ /** Whether the page has a pi for this conversation (the pool). */
57
+ open(sessionId: string): boolean;
58
+ needsYou(): ReadonlyMap<string, number>;
59
+ queued(sessionId: string): number;
60
+ /** Projects named by a lease or a job, which are offered even with no session file. */
61
+ projectCwds(): readonly string[];
62
+ }
63
+
64
+ /** Rows in one answer; the rest are counted in `more`. */
65
+ export const ROW_CAP = 500;
66
+ /** Entries looked at in one directory, as `listSessionsHere` cuts them (src/page-children.ts). */
67
+ export const DIR_ENTRIES_CAP = 2000;
68
+ /** Heads kept in memory; past this the cache starts again. */
69
+ export const HEAD_CACHE_CAP = 2000;
70
+ /** Characters of a title. */
71
+ export const TITLE_CHARS = 60;
72
+ /** Header ids kept in memory for `byId`; past this the oldest go first. */
73
+ export const ID_CACHE_CAP = 20_000;
74
+ /** Ids remembered as not in a directory, per directory, until its mtime changes. */
75
+ export const MISS_CAP = 1000;
76
+
77
+ /**
78
+ * Text someone typed, made safe to draw as one line: terminal escape sequences, C0 and C1 controls,
79
+ * and the bidi embeddings, overrides and isolates go, as the page's `plain()` takes them out
80
+ * (src/web.mjs). Escaping stops markup; it does nothing about a U+202E that reorders the line.
81
+ */
82
+ export function plainText(text: string): string {
83
+ return String(text ?? "")
84
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
85
+ .replace(/\u001b[[(][0-9;?]*[ -/]*[@-~]/g, "")
86
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
87
+ .replace(/[\u202a-\u202e\u2066-\u2069]/g, "");
88
+ }
89
+
90
+ /** The id in a session file's first line, read from its first 4 KB; a longer first line is read whole by `readSessionHead`. */
91
+ export function readHeaderId(file: string): string | undefined {
92
+ let text = "";
93
+ try {
94
+ const fd = fs.openSync(file, "r");
95
+ try {
96
+ const buf = Buffer.alloc(4096);
97
+ text = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, buf.length, 0));
98
+ } finally {
99
+ fs.closeSync(fd);
100
+ }
101
+ } catch {
102
+ return undefined; // gone, or not readable
103
+ }
104
+ const cut = text.indexOf("\n");
105
+ if (cut < 0 && text.length >= 4000) return readSessionHead(file)?.id;
106
+ try {
107
+ const header = JSON.parse(cut < 0 ? text : text.slice(0, cut));
108
+ return header?.type === "session" && typeof header.id === "string" ? header.id : undefined;
109
+ } catch {
110
+ return undefined; // a torn header: not a session file anyone can open
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The pi arguments for a conversation the page starts with none recorded: those of the conversation
116
+ * in the same project opened most recently with any, so a project whose pages load pi-loops with
117
+ * `-e` loads it in this one too. Taken only from what the pool recorded, never from HTTP.
118
+ */
119
+ export function projectArgs(cwd: string, candidates: ReadonlyArray<{ cwd?: string; at: number; args: readonly string[] }>): string[] {
120
+ const best = candidates.filter((c) => c.cwd === cwd && c.args.length > 0).sort((a, b) => (b.at || 0) - (a.at || 0))[0];
121
+ return best ? withoutConversation(best.args) : [];
122
+ }
123
+
124
+ /** pi's arguments that pick or name a conversation, and take a value: another conversation's are not this one's. */
125
+ const CONVERSATION_ARGS = new Set(["--session", "--session-id", "--fork", "--session-dir", "--name", "-n", "--export"]);
126
+ /**
127
+ * And those that take none, with the person's one-time answers: `--approve` (`-a`) trusts a project
128
+ * for that run, and a later conversation opened from the list must not inherit that answer.
129
+ */
130
+ const CONVERSATION_FLAGS = new Set(["--continue", "-c", "--resume", "-r", "--no-session", "--approve", "-a"]);
131
+
132
+ /** `args` without the ones that say which conversation: what loads pi-loops (`-e`), a model, a flag stay. */
133
+ function withoutConversation(args: readonly string[]): string[] {
134
+ const out: string[] = [];
135
+ for (let i = 0; i < args.length; i++) {
136
+ const a = args[i];
137
+ if (CONVERSATION_FLAGS.has(a)) continue;
138
+ if (CONVERSATION_ARGS.has(a)) {
139
+ i++;
140
+ continue;
141
+ }
142
+ if ([...CONVERSATION_ARGS].some((k) => k.startsWith("--") && a.startsWith(`${k}=`))) continue;
143
+ out.push(a);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** A map used as a cache that drops its least recently used entry past `cap`. */
149
+ function lruGet<K, V>(map: Map<K, V>, key: K): V | undefined {
150
+ const v = map.get(key);
151
+ if (v !== undefined) {
152
+ map.delete(key);
153
+ map.set(key, v);
154
+ }
155
+ return v;
156
+ }
157
+ function lruSet<K, V>(map: Map<K, V>, key: K, v: V, cap: number): void {
158
+ map.delete(key);
159
+ map.set(key, v);
160
+ while (map.size > cap) map.delete(map.keys().next().value as K);
161
+ }
162
+
163
+ /**
164
+ * The holder of a conversation, from its lease. The rule `sessionRows` applies (src/page-server.ts):
165
+ * a live lease written by a page child is the page's, another live terminal lease is a terminal's, a
166
+ * live host lease is pi-loops in the background. A pool child whose lease is not written yet is the
167
+ * page's too. `delivers` is `holderDelivers`: whether that holder answers what the page sends.
168
+ */
169
+ export function holderOf(lease: Partial<Pick<SessionLease, "holder">> | undefined, alive: (h: LeaseHolder) => boolean, openInPage: boolean): { kind: HolderKind; pid?: number; delivers: boolean } {
170
+ const h = lease?.holder;
171
+ if (h && alive(h)) {
172
+ const kind: HolderKind = h.kind === "host" ? "background" : h.page === true ? "page" : "terminal";
173
+ return { kind, pid: h.pid, delivers: holderDelivers(h) };
174
+ }
175
+ return openInPage ? { kind: "page", delivers: true } : { kind: "none", delivers: false };
176
+ }
177
+
178
+ /** The name someone gave a conversation, else the first thing said in it, as one line of 60 characters. */
179
+ export function titleOf(head: Pick<SessionHead, "name" | "first"> | undefined): string | undefined {
180
+ const text = plainText(head?.name ?? "").trim() || plainText(head?.first ?? "").trim();
181
+ return text ? previewText(text, TITLE_CHARS) : undefined;
182
+ }
183
+
184
+ /** A project's label: its cwd, with the home directory written `~`. */
185
+ export function projectLabel(cwd: string, home: string = os.homedir()): string {
186
+ if (home && home !== "/" && (cwd === home || cwd.startsWith(home + path.sep))) return `~${cwd.slice(home.length)}`;
187
+ return cwd;
188
+ }
189
+
190
+ /** Open decisions in `inbox.jsonl`, by the session that created the loop that filed them. A torn line is skipped. */
191
+ export function countNeedsYou(text: string): Map<string, number> {
192
+ const out = new Map<string, number>();
193
+ for (const line of text.split("\n")) {
194
+ if (!line.trim()) continue;
195
+ let e: any;
196
+ try {
197
+ e = JSON.parse(line);
198
+ } catch {
199
+ continue; // a torn line: the inbox skips it too
200
+ }
201
+ if ((e?.status ?? "new") !== "new" || e?.kind !== "checkpoint" || typeof e?.session_id !== "string") continue;
202
+ // As `Inbox.listNew` reads it: a finding with no id or no text is no card, so it is no count.
203
+ if (typeof e.id !== "string" || !e.id || typeof e.text !== "string" || !e.text) continue;
204
+ out.set(e.session_id, (out.get(e.session_id) ?? 0) + 1);
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /** `countNeedsYou` of one file, read again only when its size or mtime changed. */
210
+ export class NeedsYouCounts {
211
+ private readonly file: string;
212
+ private at?: { mtimeMs: number; size: number; counts: Map<string, number> };
213
+ constructor(file: string) {
214
+ this.file = file;
215
+ }
216
+ get(): ReadonlyMap<string, number> {
217
+ let st: fs.Stats;
218
+ try {
219
+ st = fs.statSync(this.file);
220
+ } catch {
221
+ return new Map(); // no inbox yet
222
+ }
223
+ if (this.at && this.at.mtimeMs === st.mtimeMs && this.at.size === st.size) return this.at.counts;
224
+ let text = "";
225
+ try {
226
+ text = fs.readFileSync(this.file, "utf8");
227
+ } catch {
228
+ return new Map(); // gone between the stat and the read
229
+ }
230
+ this.at = { mtimeMs: st.mtimeMs, size: st.size, counts: countNeedsYou(text) };
231
+ return this.at.counts;
232
+ }
233
+ }
234
+
235
+ interface SessionFile {
236
+ file: string;
237
+ mtimeMs: number;
238
+ size: number;
239
+ }
240
+
241
+ interface Project {
242
+ dirs: string[];
243
+ lastMs: number;
244
+ files: number;
245
+ }
246
+
247
+ /** One look at every directory: the projects found, by cwd. Asked once per answer and handed to both `projects` and `rows`. */
248
+ export type ProjectScan = ReadonlyMap<string, Project>;
249
+
250
+ /**
251
+ * pi's session files, read for the list. One per page server, in memory; it reads nothing until it
252
+ * is asked. Each file's head is kept with the size and mtime it was read at, so a warm answer reads
253
+ * only the files that changed since (usually one: the conversation being written).
254
+ *
255
+ * `sessionsRoot` is pi's sessions directory. Without it, the only directories looked at are those of
256
+ * the session files that leases name (`leaseFiles`), so a server built without it never reads the
257
+ * person's real `~/.pi`.
258
+ */
259
+ export class ConversationIndex {
260
+ private readonly heads = new Map<string, { mtimeMs: number; size: number; head: SessionHead | undefined }>();
261
+ /** Header ids by file: a header is written once, so an id read stays the file's id. */
262
+ private readonly ids = new Map<string, string | null>();
263
+ /** Ids `byId` looked for in a directory and did not find, with the directory's mtime then. */
264
+ private readonly misses = new Map<string, { mtimeMs: number; ids: Set<string> }>();
265
+ private readonly sessionsRoot: string | undefined;
266
+ private readonly read: HeadReader;
267
+ private readonly leaseFiles: () => readonly string[];
268
+ private readonly readId: IdReader;
269
+ /** How many times every directory was looked at: one per answer. */
270
+ scans = 0;
271
+
272
+ constructor(sessionsRoot: string | undefined, read: HeadReader = (file) => readSessionHead(file), leaseFiles: () => readonly string[] = () => [], readId: IdReader = readHeaderId) {
273
+ this.sessionsRoot = sessionsRoot;
274
+ this.read = read;
275
+ this.leaseFiles = leaseFiles;
276
+ this.readId = readId;
277
+ }
278
+
279
+ /** The head of `f`, read only when it changed since the last read. */
280
+ private head(f: SessionFile): SessionHead | undefined {
281
+ const have = lruGet(this.heads, f.file);
282
+ if (have && have.mtimeMs === f.mtimeMs && have.size === f.size) return have.head;
283
+ const head = this.read(f.file);
284
+ lruSet(this.heads, f.file, { mtimeMs: f.mtimeMs, size: f.size, head }, HEAD_CACHE_CAP);
285
+ return head;
286
+ }
287
+
288
+ /** The header id of `file`: a head already read says it, else its first line, once. */
289
+ private idOf(f: SessionFile): string | undefined {
290
+ const head = this.heads.get(f.file);
291
+ if (head && head.mtimeMs === f.mtimeMs && head.size === f.size) return head.head?.id;
292
+ const have = lruGet(this.ids, f.file);
293
+ if (have !== undefined) return have ?? undefined;
294
+ const id = this.readId(f.file);
295
+ // Only an id that was there: a header being written when it was read is read again next time,
296
+ // not remembered as having none.
297
+ if (id !== undefined) lruSet(this.ids, f.file, id, ID_CACHE_CAP);
298
+ return id;
299
+ }
300
+
301
+ /** The directories a conversation can be in: the sessions root's own, else the ones leases name. */
302
+ private dirs(): string[] {
303
+ if (this.sessionsRoot === undefined) {
304
+ return [...new Set(this.leaseFiles().filter((f) => typeof f === "string" && path.isAbsolute(f)).map((f) => path.dirname(f)))];
305
+ }
306
+ let entries: fs.Dirent[];
307
+ try {
308
+ entries = fs.readdirSync(this.sessionsRoot, { withFileTypes: true });
309
+ } catch {
310
+ return []; // pi has saved nothing yet
311
+ }
312
+ return entries
313
+ .slice(0, DIR_ENTRIES_CAP)
314
+ .filter((e) => e.isDirectory())
315
+ .map((e) => path.join(this.sessionsRoot as string, e.name));
316
+ }
317
+
318
+ /** The session files in `dir`, newest first. A link is not one: it could point anywhere. */
319
+ private files(dir: string): SessionFile[] {
320
+ let names: string[];
321
+ try {
322
+ names = fs.readdirSync(dir);
323
+ } catch {
324
+ return []; // gone, or not a directory
325
+ }
326
+ const out: SessionFile[] = [];
327
+ for (const name of names.slice(0, DIR_ENTRIES_CAP)) {
328
+ if (!name.endsWith(".jsonl")) continue;
329
+ const file = path.join(dir, name);
330
+ let st: fs.Stats;
331
+ try {
332
+ st = fs.lstatSync(file);
333
+ } catch {
334
+ continue; // removed while listed
335
+ }
336
+ if (st.isFile()) out.push({ file, mtimeMs: st.mtimeMs, size: st.size });
337
+ }
338
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
339
+ }
340
+
341
+ /** Every directory, keyed by the cwd in its newest readable header. */
342
+ scan(): ProjectScan {
343
+ this.scans++;
344
+ const out = new Map<string, Project>();
345
+ for (const dir of this.dirs()) {
346
+ const files = this.files(dir);
347
+ let cwd: string | undefined;
348
+ // The newest file says which project this is; a torn or foreign newest file passes the
349
+ // question to the next one, a few times at most.
350
+ for (const f of files.slice(0, 5)) {
351
+ cwd = this.head(f)?.cwd || undefined;
352
+ if (cwd) break;
353
+ }
354
+ if (!cwd) continue;
355
+ const have = out.get(cwd);
356
+ const lastMs = files[0]?.mtimeMs ?? 0;
357
+ if (have) {
358
+ have.dirs.push(dir);
359
+ have.lastMs = Math.max(have.lastMs, lastMs);
360
+ have.files += files.length;
361
+ } else out.set(cwd, { dirs: [dir], lastMs, files: files.length });
362
+ }
363
+ return out;
364
+ }
365
+
366
+ /** The projects to offer, newest first: those with conversations, then those only a lease or a job names. */
367
+ projects(readers: Pick<RowReaders, "projectCwds">, scanned: ProjectScan = this.scan()): ProjectRow[] {
368
+ const rows: ProjectRow[] = [...scanned].sort((a, b) => b[1].lastMs - a[1].lastMs).map(([cwd, p]) => ({ cwd, label: projectLabel(cwd), lastActivityAt: stamp(p.lastMs), conversations: p.files }));
369
+ const extra = [...new Set(readers.projectCwds().filter((c) => typeof c === "string" && c !== "" && !scanned.has(c)))].sort();
370
+ return [...rows, ...extra.map((cwd) => ({ cwd, label: projectLabel(cwd), conversations: 0 }))];
371
+ }
372
+
373
+ /**
374
+ * The conversations of the project `cwd`, newest first, at most `ROW_CAP`. Undefined for a cwd
375
+ * that is not a project here: `cwd` is a key into what was found, and nothing else is looked up.
376
+ */
377
+ rows(cwd: string, readers: RowReaders, scanned: ProjectScan = this.scan()): { rows: ConversationRow[]; more: number } | undefined {
378
+ const project = scanned.get(cwd);
379
+ if (!project) return readers.projectCwds().includes(cwd) ? { rows: [], more: 0 } : undefined;
380
+ const files = project.dirs.flatMap((dir) => this.files(dir)).sort((a, b) => b.mtimeMs - a.mtimeMs);
381
+ const needsYou = readers.needsYou();
382
+ const seen = new Set<string>();
383
+ const rows: ConversationRow[] = [];
384
+ let at = 0;
385
+ for (; at < files.length && rows.length < ROW_CAP; at++) {
386
+ const f = files[at];
387
+ const head = this.head(f);
388
+ // Not a session file, or a torn header: not a conversation anyone can open.
389
+ if (!head || seen.has(head.id)) continue;
390
+ seen.add(head.id);
391
+ const open = readers.open(head.id);
392
+ const title = titleOf(head);
393
+ const holder = holderOf(readers.lease(head.id), readers.alive, open);
394
+ rows.push({
395
+ sessionId: head.id,
396
+ cwd,
397
+ ...(title !== undefined ? { title } : {}),
398
+ lastActivityAt: stamp(f.mtimeMs),
399
+ holder: holder.kind,
400
+ delivers: holder.delivers,
401
+ open,
402
+ needsYou: needsYou.get(head.id) ?? 0,
403
+ queued: readers.queued(head.id),
404
+ });
405
+ }
406
+ return { rows, more: files.length - at };
407
+ }
408
+
409
+ /**
410
+ * The session file whose header id is `id`, and the cwd that header names; undefined for an id
411
+ * that is not a usable file name or that no file here has. What `pi --session <id>` accepts, under
412
+ * pi's sessions root only.
413
+ *
414
+ * Asked on every load of a conversation no lease names and no child has, the page's 8 s poll
415
+ * included, on the loop everything else runs on, so it is cheap when the answer is no. Files named
416
+ * after the id (`<stamp>_<id>.jsonl`, pi's own naming) are looked at first. Then the rest, by the
417
+ * header's first line alone, each read once; and an id not found in a directory is not looked for
418
+ * there again until a file is added to it or taken away (its mtime changes).
419
+ */
420
+ byId(id: string): { sessionFile: string; cwd: string } | undefined {
421
+ try {
422
+ sessionFileName(id);
423
+ } catch {
424
+ return undefined; // `..`, a slash: not an id
425
+ }
426
+ const dirs = this.dirs();
427
+ const found = (f: SessionFile) => {
428
+ const head = this.head(f);
429
+ return head?.id === id && head.cwd ? { sessionFile: f.file, cwd: head.cwd } : undefined;
430
+ };
431
+ const listed = new Map<string, SessionFile[]>();
432
+ const filesOf = (dir: string) => {
433
+ if (!listed.has(dir)) listed.set(dir, this.files(dir));
434
+ return listed.get(dir) as SessionFile[];
435
+ };
436
+ for (const dir of dirs) {
437
+ let names: string[];
438
+ try {
439
+ names = fs.readdirSync(dir).slice(0, DIR_ENTRIES_CAP);
440
+ } catch {
441
+ continue; // gone, or not a directory
442
+ }
443
+ if (!names.some((n) => n.endsWith(".jsonl") && n.includes(id))) continue;
444
+ for (const f of filesOf(dir)) if (path.basename(f.file).includes(id)) {
445
+ const hit = found(f);
446
+ if (hit) return hit;
447
+ }
448
+ }
449
+ for (const dir of dirs) {
450
+ let mtimeMs: number;
451
+ try {
452
+ mtimeMs = fs.statSync(dir).mtimeMs;
453
+ } catch {
454
+ continue; // gone since it was listed
455
+ }
456
+ const missed = this.misses.get(dir);
457
+ if (missed && missed.mtimeMs === mtimeMs && missed.ids.has(id)) continue;
458
+ for (const f of filesOf(dir)) if (this.idOf(f) === id) {
459
+ const hit = found(f);
460
+ if (hit) return hit;
461
+ }
462
+ const keep = missed && missed.mtimeMs === mtimeMs ? missed : { mtimeMs, ids: new Set<string>() };
463
+ if (keep.ids.size >= MISS_CAP) keep.ids.clear();
464
+ keep.ids.add(id);
465
+ this.misses.set(dir, keep);
466
+ }
467
+ return undefined;
468
+ }
469
+ }