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.
Files changed (71) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -3
  3. package/packages/core/src/extensions/CoreExtensions.ts +19 -16
  4. package/packages/core/src/extensions/apply-patch/render.ts +16 -55
  5. package/packages/core/src/extensions/bash/index.ts +2 -1
  6. package/packages/core/src/extensions/bash/run.ts +8 -2
  7. package/packages/core/src/extensions/system-prompt/index.ts +45 -22
  8. package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
  9. package/packages/core/src/picker/PickerService.ts +23 -1
  10. package/packages/core/src/picker/token.ts +12 -5
  11. package/packages/core/src/session/EventLog.ts +22 -79
  12. package/packages/core/src/session/SearchIndex.ts +752 -0
  13. package/packages/core/src/session/SearchTokens.ts +97 -0
  14. package/packages/core/src/session/SessionDigest.ts +161 -0
  15. package/packages/core/src/session/SessionHost.ts +52 -10
  16. package/packages/core/src/session/SessionMeta.ts +267 -0
  17. package/packages/core/src/session/SessionName.ts +18 -0
  18. package/packages/core/src/session/SessionRegistry.ts +111 -15
  19. package/packages/core/src/session/SessionUi.ts +127 -0
  20. package/packages/core/src/session/WriteMark.ts +15 -1
  21. package/packages/core/src/shared/DiffLines.ts +1 -1
  22. package/packages/core/src/shared/DiffPatch.ts +70 -0
  23. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  24. package/packages/core/src/shared/Directories.ts +39 -5
  25. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  26. package/packages/core/src/shared/FileWatch.ts +15 -1
  27. package/packages/core/src/shared/Git.ts +483 -42
  28. package/packages/core/src/shared/GitMonitor.ts +221 -0
  29. package/packages/core/src/shared/Levenshtein.ts +54 -1
  30. package/packages/core/src/shared/PiExtensions.ts +228 -0
  31. package/packages/core/src/shared/Proc.ts +21 -1
  32. package/packages/core/src/shared/RepoDiff.ts +741 -0
  33. package/packages/core/src/shared/Surface.ts +2 -0
  34. package/packages/core/src/shared/Updater.ts +21 -5
  35. package/packages/core/src/view/DiffExpand.ts +214 -0
  36. package/packages/core/src/view/DiffLayout.ts +7 -7
  37. package/packages/core/src/view/DiffPairs.ts +47 -0
  38. package/packages/core/src/view/MovePath.ts +70 -0
  39. package/packages/daemon/src/WebSurface.ts +1 -2
  40. package/packages/protocol/src/Command.ts +151 -3
  41. package/packages/protocol/src/Diff.ts +15 -0
  42. package/packages/protocol/src/ServerEvent.ts +191 -2
  43. package/packages/server/src/ClientConnection.ts +15 -3
  44. package/packages/server/src/ProbeClient.ts +111 -28
  45. package/packages/server/src/SessionCatalogue.ts +292 -38
  46. package/packages/server/src/SessionStream.ts +297 -45
  47. package/packages/server/src/StaticClient.ts +95 -12
  48. package/packages/server/src/WsGateway.ts +417 -45
  49. package/packages/server/src/probe.ts +77 -7
  50. package/packages/telegram/src/Session.ts +1 -0
  51. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  52. package/packages/tui/src/extensions/footer/index.ts +19 -43
  53. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  54. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  55. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
  56. package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
  57. package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
  58. package/packages/web/dist/client/index.html +2 -2
  59. package/packages/protocol/src/Protocol.ts +0 -6
  60. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  61. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  62. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  63. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  64. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  65. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  66. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  67. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  68. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  69. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  70. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  71. 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 type { SessionRegistry } from "#core/session/SessionRegistry";
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<readonly SessionSummaryView[]> {
69
- const summaries = await this.deps.registry.list(command.cwd);
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
- await this.deps.cursors.prune(
73
- new Set([
74
- ...summaries.map((summary) => summary.sessionId),
75
- ...this.deps.liveSessionIds(),
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
- // Cut by modified time and digest only the page: ordering by settle time would read every session on disk.
80
- const page = await Promise.all(
81
- summaries
82
- .slice(0, command.limit ?? DEFAULT_SESSION_LIMIT)
83
- .map(async ({ sessionId, cwd, path, createdAt, modifiedAt }) => {
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
- return page.sort((a, b) => b.settledAt - a.settledAt);
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
- return this.deps.cursors.flush();
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?.modifiedAt === modifiedAt) {
196
- return cached;
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
  }