pim-agent 0.9.0 → 0.11.0
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/bin/pim.ts +1 -1
- package/package.json +2 -3
- package/packages/core/src/extensions/CoreExtensions.ts +19 -16
- package/packages/core/src/extensions/apply-patch/render.ts +16 -55
- package/packages/core/src/extensions/bash/index.ts +2 -1
- package/packages/core/src/extensions/bash/run.ts +8 -2
- package/packages/core/src/extensions/system-prompt/index.ts +45 -22
- package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
- package/packages/core/src/picker/PickerService.ts +23 -1
- package/packages/core/src/picker/token.ts +12 -5
- package/packages/core/src/session/EventLog.ts +22 -79
- package/packages/core/src/session/SearchIndex.ts +752 -0
- package/packages/core/src/session/SearchTokens.ts +97 -0
- package/packages/core/src/session/SessionDigest.ts +161 -0
- package/packages/core/src/session/SessionHost.ts +52 -10
- package/packages/core/src/session/SessionMeta.ts +267 -0
- package/packages/core/src/session/SessionName.ts +18 -0
- package/packages/core/src/session/SessionRegistry.ts +111 -15
- package/packages/core/src/session/SessionUi.ts +127 -0
- package/packages/core/src/session/WriteMark.ts +15 -1
- package/packages/core/src/shared/DiffLines.ts +1 -1
- package/packages/core/src/shared/DiffPatch.ts +70 -0
- package/packages/core/src/shared/DiffRenderer.ts +1 -1
- package/packages/core/src/shared/Directories.ts +39 -5
- package/packages/core/src/shared/ExtensionToggles.ts +8 -0
- package/packages/core/src/shared/FileWatch.ts +15 -1
- package/packages/core/src/shared/Git.ts +483 -42
- package/packages/core/src/shared/GitMonitor.ts +221 -0
- package/packages/core/src/shared/Levenshtein.ts +54 -1
- package/packages/core/src/shared/PiExtensions.ts +228 -0
- package/packages/core/src/shared/Proc.ts +21 -1
- package/packages/core/src/shared/RepoDiff.ts +741 -0
- package/packages/core/src/shared/Surface.ts +2 -0
- package/packages/core/src/shared/Updater.ts +21 -5
- package/packages/core/src/view/DiffExpand.ts +214 -0
- package/packages/core/src/view/DiffLayout.ts +7 -7
- package/packages/core/src/view/DiffPairs.ts +47 -0
- package/packages/core/src/view/MovePath.ts +70 -0
- package/packages/daemon/src/WebSurface.ts +1 -2
- package/packages/protocol/src/Command.ts +151 -3
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +191 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +111 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +297 -45
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +417 -45
- package/packages/server/src/probe.ts +77 -7
- package/packages/telegram/src/Session.ts +1 -0
- package/packages/tui/src/extensions/command-picker/index.ts +64 -32
- package/packages/tui/src/extensions/footer/index.ts +19 -43
- package/packages/tui/src/extensions/session-lease/index.ts +8 -3
- package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
- package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
- package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { EventLog } from "#core/session/EventLog";
|
|
2
2
|
import type { SessionDigest } from "#core/session/EventLog";
|
|
3
3
|
import type { ReadCursors } from "#core/session/ReadCursors";
|
|
4
|
-
import
|
|
4
|
+
import { SearchIndex } from "#core/session/SearchIndex";
|
|
5
|
+
import type { SearchHit } from "#core/session/SearchIndex";
|
|
6
|
+
import type {
|
|
7
|
+
Pinning,
|
|
8
|
+
SessionEntry,
|
|
9
|
+
SessionMeta,
|
|
10
|
+
} from "#core/session/SessionMeta";
|
|
11
|
+
import type {
|
|
12
|
+
SessionRegistry,
|
|
13
|
+
SessionSummary,
|
|
14
|
+
} from "#core/session/SessionRegistry";
|
|
5
15
|
import { FileWatch } from "#core/shared/FileWatch";
|
|
16
|
+
import { Pool } from "#core/shared/Pool";
|
|
6
17
|
import type { Command } from "#protocol/Command";
|
|
7
18
|
import type {
|
|
19
|
+
ProjectView,
|
|
20
|
+
SearchHitView,
|
|
8
21
|
ServerEvent,
|
|
22
|
+
SessionListing,
|
|
23
|
+
SessionSearch,
|
|
9
24
|
SessionStatus,
|
|
10
25
|
SessionSummaryView,
|
|
11
26
|
} from "#protocol/ServerEvent";
|
|
@@ -14,6 +29,8 @@ export type SessionCatalogueDeps = {
|
|
|
14
29
|
readonly registry: SessionRegistry;
|
|
15
30
|
/** Which sessions have been read, shared by every client. */
|
|
16
31
|
readonly cursors: ReadCursors;
|
|
32
|
+
/** Archived, held-unread and pinned, shared by every client. */
|
|
33
|
+
readonly meta: SessionMeta;
|
|
17
34
|
readonly liveStatus: (sessionId: string) => SessionStatus | undefined;
|
|
18
35
|
readonly liveSessionIds: () => Iterable<string>;
|
|
19
36
|
readonly isBeingRead: (sessionId: string) => boolean;
|
|
@@ -22,22 +39,37 @@ export type SessionCatalogueDeps = {
|
|
|
22
39
|
|
|
23
40
|
const DEFAULT_SESSION_LIMIT = 50;
|
|
24
41
|
|
|
42
|
+
/** A digest is a whole-file read; a page of them at once is a page of files in memory at once. */
|
|
43
|
+
const DIGEST_READS = 16;
|
|
44
|
+
|
|
25
45
|
/** Long enough that a turn's entries are one announcement, short enough that a new session appears while the user is still looking. */
|
|
26
46
|
const ANNOUNCE_MS = 500;
|
|
27
47
|
|
|
28
48
|
type CachedDigest = SessionDigest & { readonly modifiedAt: number };
|
|
29
49
|
|
|
50
|
+
type Overrides = ReadonlyMap<string, SessionEntry>;
|
|
51
|
+
|
|
52
|
+
type PageScope = {
|
|
53
|
+
readonly overrides: Overrides;
|
|
54
|
+
/** True while listing the archived sessions rather than the live ones. */
|
|
55
|
+
readonly archived: boolean;
|
|
56
|
+
readonly limit: number;
|
|
57
|
+
readonly perProject: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
30
60
|
export class SessionCatalogue {
|
|
31
61
|
private readonly deps: SessionCatalogueDeps;
|
|
32
62
|
private readonly digests = new Map<string, CachedDigest>();
|
|
33
63
|
private readonly activity = new Map<string, SessionStatus>();
|
|
34
64
|
private readonly settled = new Map<string, number>();
|
|
35
65
|
private readonly watches = new Map<string, () => void>();
|
|
66
|
+
private index: SearchIndex;
|
|
36
67
|
private watching = false;
|
|
37
68
|
private announcing: ReturnType<typeof setTimeout> | undefined;
|
|
38
69
|
|
|
39
70
|
public constructor(deps: SessionCatalogueDeps) {
|
|
40
71
|
this.deps = deps;
|
|
72
|
+
this.index = this.newIndex();
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
/**
|
|
@@ -65,41 +97,69 @@ export class SessionCatalogue {
|
|
|
65
97
|
|
|
66
98
|
public async list(
|
|
67
99
|
command: Command & { readonly type: "list_sessions" }
|
|
68
|
-
): Promise<
|
|
69
|
-
const summaries = await
|
|
100
|
+
): Promise<SessionListing> {
|
|
101
|
+
const [summaries, overrides, pinning] = await Promise.all([
|
|
102
|
+
this.deps.registry.list(command.cwd),
|
|
103
|
+
this.deps.meta.sessions(),
|
|
104
|
+
this.deps.meta.pinning(),
|
|
105
|
+
]);
|
|
70
106
|
// Prune only on an unfiltered listing: a cwd-filtered one would forget every other directory.
|
|
71
107
|
if (command.cwd === undefined) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
108
|
+
const alive = new Set([
|
|
109
|
+
...summaries.map((summary) => summary.sessionId),
|
|
110
|
+
...this.deps.liveSessionIds(),
|
|
111
|
+
]);
|
|
112
|
+
await Promise.all([
|
|
113
|
+
this.deps.cursors.prune(alive),
|
|
114
|
+
this.deps.meta.prune(alive),
|
|
115
|
+
]);
|
|
78
116
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
const { title, settledAt } = await this.digestOf(path, modifiedAt);
|
|
85
|
-
const status = this.deps.liveStatus(sessionId);
|
|
86
|
-
const answeredAt = this.answerTime(sessionId, status, settledAt);
|
|
87
|
-
const unread = await this.deps.cursors.isUnread(
|
|
88
|
-
sessionId,
|
|
89
|
-
answeredAt
|
|
90
|
-
);
|
|
91
|
-
return {
|
|
92
|
-
sessionId,
|
|
93
|
-
cwd,
|
|
94
|
-
createdAt,
|
|
95
|
-
settledAt: answeredAt ?? settledAt ?? createdAt,
|
|
96
|
-
...(title === undefined ? {} : { title }),
|
|
97
|
-
...(status === undefined || status === "idle" ? {} : { status }),
|
|
98
|
-
...(unread ? { unread: true } : {}),
|
|
99
|
-
};
|
|
100
|
-
})
|
|
117
|
+
const scope = command.archived === true;
|
|
118
|
+
// Before the cut: an archived row that ate the page budget would push a live one off the end.
|
|
119
|
+
const inScope = summaries.filter(
|
|
120
|
+
(summary) =>
|
|
121
|
+
(overrides.get(summary.sessionId)?.archived === true) === scope
|
|
101
122
|
);
|
|
102
|
-
|
|
123
|
+
const limit = command.limit ?? DEFAULT_SESSION_LIMIT;
|
|
124
|
+
return {
|
|
125
|
+
sessions: await this.page(inScope, {
|
|
126
|
+
overrides,
|
|
127
|
+
archived: scope,
|
|
128
|
+
limit,
|
|
129
|
+
// Absent, a project may fill the page; it is the page that bounds it either way.
|
|
130
|
+
perProject: command.perProject ?? limit,
|
|
131
|
+
}),
|
|
132
|
+
// Counted before the cut, and over the whole scope: a collapsed group says how many it holds, and a row the cut dropped is one a client can still ask for.
|
|
133
|
+
projects: projectsOf(inScope, pinning),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The index cannot see the sidecar, so the archived scope is a predicate it
|
|
139
|
+
* calls before its own limit, and the badge is a `Map.get` over the one read
|
|
140
|
+
* of `sessions.json` this query makes.
|
|
141
|
+
*/
|
|
142
|
+
public async search(
|
|
143
|
+
command: Command & { readonly type: "search_sessions" }
|
|
144
|
+
): Promise<SessionSearch> {
|
|
145
|
+
const overrides = await this.deps.meta.sessions();
|
|
146
|
+
const archivedOf = (sessionId: string): boolean =>
|
|
147
|
+
overrides.get(sessionId)?.archived === true;
|
|
148
|
+
const { hits, dropped, scanned } = await this.index.search(command.query, {
|
|
149
|
+
...(command.limit === undefined ? {} : { limit: command.limit }),
|
|
150
|
+
...(command.cwd === undefined ? {} : { cwd: command.cwd }),
|
|
151
|
+
...(command.archived === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: {
|
|
154
|
+
accept: (sessionId: string) =>
|
|
155
|
+
archivedOf(sessionId) === command.archived,
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
hits: hits.map((hit) => hitOf(hit, archivedOf(hit.sessionId))),
|
|
160
|
+
dropped,
|
|
161
|
+
scanned,
|
|
162
|
+
};
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
/** Announces a status edge to every client; the announcement must precede the read mark. */
|
|
@@ -118,20 +178,99 @@ export class SessionCatalogue {
|
|
|
118
178
|
this.activity.set(sessionId, status);
|
|
119
179
|
}
|
|
120
180
|
|
|
121
|
-
/** Moves a session's read cursor to now and says so to every client. */
|
|
181
|
+
/** Moves a session's read cursor to now, drops the mark a reader left on it by hand, and says so to every client. */
|
|
122
182
|
public async markRead(sessionId: string): Promise<void> {
|
|
123
183
|
await this.deps.cursors.mark(sessionId);
|
|
184
|
+
// The only place the sticky flag is cleared; a listing must never clear it.
|
|
185
|
+
if ((await this.deps.meta.of(sessionId)).unread === true) {
|
|
186
|
+
await this.deps.meta.setUnread(sessionId, false);
|
|
187
|
+
}
|
|
124
188
|
this.deps.announce({ type: "session_read", sessionId });
|
|
125
189
|
}
|
|
126
190
|
|
|
127
|
-
public flush(): Promise<void> {
|
|
128
|
-
|
|
191
|
+
public async flush(): Promise<void> {
|
|
192
|
+
await Promise.all([this.deps.cursors.flush(), this.deps.meta.flush()]);
|
|
129
193
|
}
|
|
130
194
|
|
|
131
195
|
public clear(): void {
|
|
132
196
|
this.digests.clear();
|
|
133
197
|
this.activity.clear();
|
|
134
198
|
this.settled.clear();
|
|
199
|
+
this.index = this.newIndex();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One index per catalogue, and so one per session root; the registry's listing is the tree it follows. */
|
|
203
|
+
private newIndex(): SearchIndex {
|
|
204
|
+
return new SearchIndex({ list: () => this.deps.registry.list() });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Selects by modified time under a per-project budget and digests only what
|
|
209
|
+
* it selects: ordering by settle time would read every session on disk. A
|
|
210
|
+
* session with nothing to call itself is not a row, and the budget it spent
|
|
211
|
+
* goes back to its project, so a directory of abandoned files still shows
|
|
212
|
+
* the sessions underneath them.
|
|
213
|
+
*/
|
|
214
|
+
private async page(
|
|
215
|
+
candidates: readonly SessionSummary[],
|
|
216
|
+
scope: PageScope
|
|
217
|
+
): Promise<readonly SessionSummaryView[]> {
|
|
218
|
+
const rows: SessionSummaryView[] = [];
|
|
219
|
+
const taken = new Map<string, number>();
|
|
220
|
+
const consumed = new Set<SessionSummary>();
|
|
221
|
+
for (;;) {
|
|
222
|
+
const batch = choose(candidates, consumed, taken, {
|
|
223
|
+
room: scope.limit - rows.length,
|
|
224
|
+
perProject: scope.perProject,
|
|
225
|
+
});
|
|
226
|
+
if (batch.length === 0) {
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
const built = await Pool.mapPooled(batch, DIGEST_READS, (summary) =>
|
|
230
|
+
this.row(summary, scope)
|
|
231
|
+
);
|
|
232
|
+
for (const [index, row] of built.entries()) {
|
|
233
|
+
if (row === undefined) {
|
|
234
|
+
const { cwd } = batch[index]!;
|
|
235
|
+
taken.set(cwd, (taken.get(cwd) ?? 1) - 1);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
rows.push(row);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return rows.sort((a, b) => b.settledAt - a.settledAt);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Nothing for a session that never asked anything and was never named: the row it would draw is a truncated UUID. */
|
|
245
|
+
private async row(
|
|
246
|
+
{ sessionId, cwd, path, createdAt, modifiedAt }: SessionSummary,
|
|
247
|
+
{ overrides, archived }: PageScope
|
|
248
|
+
): Promise<SessionSummaryView | undefined> {
|
|
249
|
+
const status = this.deps.liveStatus(sessionId);
|
|
250
|
+
const { title, named, settledAt } = await this.digestOf(
|
|
251
|
+
sessionId,
|
|
252
|
+
path,
|
|
253
|
+
modifiedAt,
|
|
254
|
+
status
|
|
255
|
+
);
|
|
256
|
+
if (title === undefined) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
const answeredAt = this.answerTime(sessionId, status, settledAt);
|
|
260
|
+
const unread =
|
|
261
|
+
overrides.get(sessionId)?.unread === true ||
|
|
262
|
+
(await this.deps.cursors.isUnread(sessionId, answeredAt));
|
|
263
|
+
return {
|
|
264
|
+
sessionId,
|
|
265
|
+
cwd,
|
|
266
|
+
createdAt,
|
|
267
|
+
settledAt: answeredAt ?? settledAt ?? createdAt,
|
|
268
|
+
title,
|
|
269
|
+
...(named === true ? { named } : {}),
|
|
270
|
+
...(archived ? { archived: true as const } : {}),
|
|
271
|
+
...(status === undefined || status === "idle" ? {} : { status }),
|
|
272
|
+
...(unread ? { unread: true } : {}),
|
|
273
|
+
};
|
|
135
274
|
}
|
|
136
275
|
|
|
137
276
|
// One watch per directory, never a recursive one: `fs.watch` recursion is unsupported on some platforms and silent on others.
|
|
@@ -188,15 +327,130 @@ export class SessionCatalogue {
|
|
|
188
327
|
}
|
|
189
328
|
|
|
190
329
|
private async digestOf(
|
|
330
|
+
sessionId: string,
|
|
191
331
|
path: string,
|
|
192
|
-
modifiedAt: number
|
|
332
|
+
modifiedAt: number,
|
|
333
|
+
status: SessionStatus | undefined
|
|
193
334
|
): Promise<SessionDigest> {
|
|
335
|
+
const name = this.liveName(sessionId);
|
|
336
|
+
const digest =
|
|
337
|
+
this.reusable(path, modifiedAt, status, name) ??
|
|
338
|
+
(await this.readDigest(path, modifiedAt));
|
|
339
|
+
return renamed(digest, name);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Mid-turn the file is appended to between listings, so its digest never hits
|
|
344
|
+
* the cache and the whole-file read lands on the busiest session there is.
|
|
345
|
+
* Nothing it answers can have moved: the opening message was written long
|
|
346
|
+
* ago, the settle time is deliberately not read from the file while a turn
|
|
347
|
+
* runs, and the name is the live session's own to say.
|
|
348
|
+
*/
|
|
349
|
+
private reusable(
|
|
350
|
+
path: string,
|
|
351
|
+
modifiedAt: number,
|
|
352
|
+
status: SessionStatus | undefined,
|
|
353
|
+
name: string | null | undefined
|
|
354
|
+
): CachedDigest | undefined {
|
|
194
355
|
const cached = this.digests.get(path);
|
|
195
|
-
if (cached
|
|
196
|
-
return
|
|
356
|
+
if (cached === undefined) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
// Except a name the live session has since cleared: the opening message a
|
|
360
|
+
// nameless row falls back to is in the file and nowhere else.
|
|
361
|
+
if (name === null && cached.named === true) {
|
|
362
|
+
return undefined;
|
|
197
363
|
}
|
|
364
|
+
const working = status !== undefined && status !== "idle";
|
|
365
|
+
return cached.modifiedAt === modifiedAt || working ? cached : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async readDigest(
|
|
369
|
+
path: string,
|
|
370
|
+
modifiedAt: number
|
|
371
|
+
): Promise<SessionDigest> {
|
|
198
372
|
const digest = await new EventLog(path).digest();
|
|
199
373
|
this.digests.set(path, { ...digest, modifiedAt });
|
|
200
374
|
return digest;
|
|
201
375
|
}
|
|
376
|
+
|
|
377
|
+
/** What a live session calls itself, without reading its file; `null` is live and unnamed. */
|
|
378
|
+
private liveName(sessionId: string): string | null | undefined {
|
|
379
|
+
const agent = this.deps.registry.peek(sessionId)?.agentSession;
|
|
380
|
+
return agent === undefined
|
|
381
|
+
? undefined
|
|
382
|
+
: (agent.sessionManager.getSessionName() ?? null);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The row the wire carries: the index's hit, plus the one thing about it only the sidecar knows. */
|
|
387
|
+
function hitOf(hit: SearchHit, archived: boolean): SearchHitView {
|
|
388
|
+
return {
|
|
389
|
+
sessionId: hit.sessionId,
|
|
390
|
+
cwd: hit.cwd,
|
|
391
|
+
...(hit.title === undefined ? {} : { title: hit.title }),
|
|
392
|
+
titleRanges: hit.titleRanges,
|
|
393
|
+
...(hit.opening === undefined ? {} : { opening: hit.opening }),
|
|
394
|
+
settledAt: hit.settledAt,
|
|
395
|
+
...(archived ? { archived: true as const } : {}),
|
|
396
|
+
snippets: hit.snippets,
|
|
397
|
+
total: hit.total,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A live session's own name wins over the file's: a rename lands on the row before the digest behind it expires. */
|
|
402
|
+
function renamed(
|
|
403
|
+
digest: SessionDigest,
|
|
404
|
+
name: string | null | undefined
|
|
405
|
+
): SessionDigest {
|
|
406
|
+
return typeof name === "string"
|
|
407
|
+
? { ...digest, title: name, named: true }
|
|
408
|
+
: digest;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The next sessions to digest, newest first, skipping the projects that have spent their budget and the candidates an earlier round already took. */
|
|
412
|
+
function choose(
|
|
413
|
+
candidates: readonly SessionSummary[],
|
|
414
|
+
consumed: Set<SessionSummary>,
|
|
415
|
+
taken: Map<string, number>,
|
|
416
|
+
budget: { readonly room: number; readonly perProject: number }
|
|
417
|
+
): readonly SessionSummary[] {
|
|
418
|
+
const batch: SessionSummary[] = [];
|
|
419
|
+
for (const summary of candidates) {
|
|
420
|
+
if (batch.length >= budget.room) {
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
const used = taken.get(summary.cwd) ?? 0;
|
|
424
|
+
if (consumed.has(summary) || used >= budget.perProject) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
taken.set(summary.cwd, used + 1);
|
|
428
|
+
consumed.add(summary);
|
|
429
|
+
batch.push(summary);
|
|
430
|
+
}
|
|
431
|
+
return batch;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Every working directory the scope holds sessions in, newest first; the count is the header scan's, so it owes nothing to the page. */
|
|
435
|
+
function projectsOf(
|
|
436
|
+
summaries: readonly SessionSummary[],
|
|
437
|
+
{ projects, order }: Pinning
|
|
438
|
+
): readonly ProjectView[] {
|
|
439
|
+
const counts = new Map<string, number>();
|
|
440
|
+
for (const { cwd } of summaries) {
|
|
441
|
+
counts.set(cwd, (counts.get(cwd) ?? 0) + 1);
|
|
442
|
+
}
|
|
443
|
+
const ranks = new Map(order.map((cwd, rank) => [cwd, rank]));
|
|
444
|
+
return [...counts].map(([cwd, count]) => {
|
|
445
|
+
const entry = projects.get(cwd);
|
|
446
|
+
return {
|
|
447
|
+
cwd,
|
|
448
|
+
count,
|
|
449
|
+
...(entry?.pinned === true
|
|
450
|
+
? { pinned: true as const, pinRank: ranks.get(cwd) ?? 0 }
|
|
451
|
+
: {}),
|
|
452
|
+
...(entry?.expanded === true ? { expanded: true as const } : {}),
|
|
453
|
+
...(entry?.label === undefined ? {} : { label: entry.label }),
|
|
454
|
+
};
|
|
455
|
+
});
|
|
202
456
|
}
|