pim-agent 0.9.0 → 0.10.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 (56) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -2
  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/system-prompt/index.ts +29 -21
  6. package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
  7. package/packages/core/src/picker/PickerService.ts +23 -1
  8. package/packages/core/src/picker/token.ts +12 -5
  9. package/packages/core/src/session/EventLog.ts +22 -79
  10. package/packages/core/src/session/SearchIndex.ts +752 -0
  11. package/packages/core/src/session/SearchTokens.ts +97 -0
  12. package/packages/core/src/session/SessionDigest.ts +161 -0
  13. package/packages/core/src/session/SessionHost.ts +17 -1
  14. package/packages/core/src/session/SessionMeta.ts +267 -0
  15. package/packages/core/src/session/SessionName.ts +18 -0
  16. package/packages/core/src/session/SessionRegistry.ts +89 -14
  17. package/packages/core/src/session/WriteMark.ts +15 -1
  18. package/packages/core/src/shared/DiffLines.ts +1 -1
  19. package/packages/core/src/shared/DiffPatch.ts +70 -0
  20. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  21. package/packages/core/src/shared/FileWatch.ts +15 -1
  22. package/packages/core/src/shared/Git.ts +483 -42
  23. package/packages/core/src/shared/GitMonitor.ts +221 -0
  24. package/packages/core/src/shared/Levenshtein.ts +54 -1
  25. package/packages/core/src/shared/Proc.ts +21 -1
  26. package/packages/core/src/shared/RepoDiff.ts +741 -0
  27. package/packages/core/src/shared/Surface.ts +2 -0
  28. package/packages/core/src/shared/Updater.ts +21 -5
  29. package/packages/core/src/view/DiffExpand.ts +214 -0
  30. package/packages/core/src/view/DiffLayout.ts +7 -7
  31. package/packages/core/src/view/DiffPairs.ts +47 -0
  32. package/packages/core/src/view/MovePath.ts +70 -0
  33. package/packages/daemon/src/WebSurface.ts +1 -2
  34. package/packages/protocol/src/Command.ts +129 -2
  35. package/packages/protocol/src/Diff.ts +15 -0
  36. package/packages/protocol/src/ServerEvent.ts +157 -2
  37. package/packages/server/src/ClientConnection.ts +15 -3
  38. package/packages/server/src/ProbeClient.ts +90 -28
  39. package/packages/server/src/SessionCatalogue.ts +292 -38
  40. package/packages/server/src/SessionStream.ts +69 -43
  41. package/packages/server/src/StaticClient.ts +95 -12
  42. package/packages/server/src/WsGateway.ts +264 -29
  43. package/packages/server/src/probe.ts +61 -7
  44. package/packages/telegram/src/Session.ts +1 -0
  45. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  46. package/packages/tui/src/extensions/footer/index.ts +19 -43
  47. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  48. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  49. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  50. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
  51. package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
  52. package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
  53. package/packages/web/dist/client/index.html +2 -2
  54. package/packages/protocol/src/Protocol.ts +0 -6
  55. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  56. package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
@@ -1,8 +1,13 @@
1
- import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ SessionManager,
3
+ type ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import { stat } from "node:fs/promises";
3
6
  import { join } from "node:path";
4
7
 
5
8
  import { Directories } from "../shared/Directories";
9
+ import { Pool } from "../shared/Pool";
10
+ import type { Surface } from "../shared/Surface";
6
11
  import { AgentRuntime, type ModelChoice } from "./AgentRuntime";
7
12
  import { EventLog } from "./EventLog";
8
13
  import { SessionCache } from "./SessionCache";
@@ -11,6 +16,8 @@ import {
11
16
  type CustomToolContext,
12
17
  type HostSettings,
13
18
  } from "./SessionHost";
19
+ import { SessionLease } from "./SessionLease";
20
+ import { SessionName } from "./SessionName";
14
21
 
15
22
  /** A session as pi stores it: one JSONL file under a cwd-encoded directory. */
16
23
  export type SessionSummary = {
@@ -33,6 +40,8 @@ export type SessionRegistryDeps = {
33
40
  ) => readonly ToolDefinition[];
34
41
  /** Appended to every session's system prompt. */
35
42
  readonly systemInstruction?: () => Promise<string | undefined>;
43
+ /** Where this frontend's human is reading. */
44
+ readonly surface?: Surface;
36
45
  };
37
46
 
38
47
  export type SessionCreateOptions = {
@@ -41,6 +50,9 @@ export type SessionCreateOptions = {
41
50
  readonly like?: SessionHost;
42
51
  };
43
52
 
53
+ /** A session tree is thousands of files and each header is one open; fanning out over all of them at once is how a listing runs the process out of descriptors. */
54
+ const HEADER_READS = 32;
55
+
44
56
  /** Live sessions keyed on pi's session UUID; the catalogue is pi's sessions directory, read on demand. */
45
57
  export class SessionRegistry {
46
58
  private readonly deps: SessionRegistryDeps;
@@ -63,18 +75,13 @@ export class SessionRegistry {
63
75
 
64
76
  /** Sessions on disk, newest first; reads only each file's header line. */
65
77
  public async list(cwd?: string): Promise<readonly SessionSummary[]> {
66
- const root = this.sessionsRoot;
67
- if (!(await stat(root).catch(() => undefined))?.isDirectory()) {
68
- return [];
69
- }
70
- const paths: string[] = [];
71
- for await (const relative of new Bun.Glob("*/*.jsonl").scan({
72
- cwd: root,
73
- onlyFiles: true,
74
- })) {
75
- paths.push(join(root, relative));
76
- }
77
- const summaries = (await Promise.all(paths.map(readSummary))).filter(
78
+ const summaries = (
79
+ await Pool.mapPooled(
80
+ await this.paths("*/*.jsonl"),
81
+ HEADER_READS,
82
+ readSummary
83
+ )
84
+ ).filter(
78
85
  (summary): summary is SessionSummary =>
79
86
  summary !== undefined && (cwd === undefined || summary.cwd === cwd)
80
87
  );
@@ -86,6 +93,38 @@ export class SessionRegistry {
86
93
  return this.hosts.peek(sessionId);
87
94
  }
88
95
 
96
+ /**
97
+ * Rename a session through pi's own session name, so its `/resume` picker shows
98
+ * it too; `null` clears it. A live session is renamed by its agent, a closed one
99
+ * by appending to its file under the turn lease. Answers with the name pi kept.
100
+ */
101
+ public async setName(
102
+ sessionId: string,
103
+ name: string | null
104
+ ): Promise<string | undefined> {
105
+ const host = this.peek(sessionId);
106
+ if (host?.agentSession) {
107
+ return await host.setName(name);
108
+ }
109
+ const summary = await this.find(sessionId);
110
+ if (summary === undefined) {
111
+ throw new Error(`unknown session: ${sessionId}`);
112
+ }
113
+ const next = SessionName.normalise(name);
114
+ // Reopening is a plain read today, but a pi that migrates the file rewrites it.
115
+ // The lease is only ever held for a whole turn, so a rename refuses rather than queues.
116
+ return await SessionLease.hold(
117
+ summary.path,
118
+ "daemon",
119
+ async () => {
120
+ const manager = SessionManager.open(summary.path);
121
+ manager.appendSessionInfo(next);
122
+ return manager.getSessionName();
123
+ },
124
+ { timeoutMs: 0 }
125
+ );
126
+ }
127
+
89
128
  /** Every model this machine has credentials for, qualified as `SessionHost.setModel` takes them. */
90
129
  public models(): readonly ModelChoice[] {
91
130
  return this.runtime.models();
@@ -96,7 +135,7 @@ export class SessionRegistry {
96
135
  if (cached) {
97
136
  return cached;
98
137
  }
99
- const summary = (await this.list()).find((s) => s.sessionId === sessionId);
138
+ const summary = await this.find(sessionId);
100
139
  if (!summary) {
101
140
  throw new Error(`unknown session: ${sessionId}`);
102
141
  }
@@ -134,6 +173,39 @@ export class SessionRegistry {
134
173
  await this.hosts.disposeAll();
135
174
  }
136
175
 
176
+ /**
177
+ * One session by id. Pi names a file for the id it carries, so the usual
178
+ * answer is one glob and one header; resolving it by listing would read the
179
+ * header of every session on disk to throw all but one away.
180
+ */
181
+ private async find(sessionId: string): Promise<SessionSummary | undefined> {
182
+ for (const path of await this.paths(`*/*${sessionId}.jsonl`)) {
183
+ const summary = await readSummary(path);
184
+ if (summary?.sessionId === sessionId) {
185
+ return summary;
186
+ }
187
+ }
188
+ // A file named something else entirely: the id is the header's, not the name's.
189
+ return (await this.list()).find(
190
+ (summary) => summary.sessionId === sessionId
191
+ );
192
+ }
193
+
194
+ private async paths(pattern: string): Promise<readonly string[]> {
195
+ const root = this.sessionsRoot;
196
+ if (!(await stat(root).catch(() => undefined))?.isDirectory()) {
197
+ return [];
198
+ }
199
+ const found: string[] = [];
200
+ for await (const relative of new Bun.Glob(pattern).scan({
201
+ cwd: root,
202
+ onlyFiles: true,
203
+ })) {
204
+ found.push(join(root, relative));
205
+ }
206
+ return found;
207
+ }
208
+
137
209
  private buildHost(label: string, settings: HostSettings): SessionHost {
138
210
  return new SessionHost({
139
211
  label: `session ${label}`,
@@ -150,6 +222,9 @@ export class SessionRegistry {
150
222
  ...(this.deps.systemInstruction === undefined
151
223
  ? {}
152
224
  : { systemInstruction: this.deps.systemInstruction }),
225
+ ...(this.deps.surface === undefined
226
+ ? {}
227
+ : { surface: this.deps.surface }),
153
228
  });
154
229
  }
155
230
  }
@@ -33,5 +33,19 @@ function foreignSince(mark: WriteMark, next: WriteMark): boolean {
33
33
  return next.head - base.head - (next.entries - base.entries) > 0;
34
34
  }
35
35
 
36
+ /**
37
+ * Whether every line the file gained since `mark` is one no reader has to replay.
38
+ * A rename appends `session_info` and moves no history, so a surface that absorbs
39
+ * it stays on the session it is already holding. Only ask between turns: a window
40
+ * that also holds our own appends is not benign, and a running turn's is.
41
+ */
42
+ async function benignSince(log: EventLog, mark: WriteMark): Promise<boolean> {
43
+ const added = await log.read(mark.head);
44
+ return (
45
+ added.length > 0 &&
46
+ added.every(({ entry }) => entry.type === "session_info")
47
+ );
48
+ }
49
+
36
50
  /** Write accounting over one session file: whose lines are these? */
37
- export const WriteMark = { UNREAD, of, foreignSince };
51
+ export const WriteMark = { UNREAD, of, foreignSince, benignSince };
@@ -301,4 +301,4 @@ function partLines(value: string): readonly string[] {
301
301
 
302
302
  const emptySide: ToolDiffSide = { lines: [], hasTrailingNewline: false };
303
303
 
304
- export const DiffLines = { buildToolDiff, fromText, emptySide };
304
+ export const DiffLines = { buildToolDiff, fromText, emptySide, attachEmphasis };
@@ -0,0 +1,70 @@
1
+ import * as Diff from "diff";
2
+ import {
3
+ DiffLines,
4
+ type ToolDiff,
5
+ type ToolDiffHunk,
6
+ type ToolDiffLine,
7
+ type ToolDiffLineKind,
8
+ } from "./DiffLines";
9
+
10
+ function fromUnified(path: string, patch: string): ToolDiff | undefined {
11
+ const hunks = Diff.parsePatch(patch)
12
+ .flatMap((file) => file.hunks)
13
+ .map(toHunk)
14
+ .filter((hunk) => hunk.lines.length > 0);
15
+
16
+ return hunks.length === 0 ? undefined : { path, hunks };
17
+ }
18
+
19
+ function toHunk(hunk: Diff.StructuredPatchHunk): ToolDiffHunk {
20
+ const lines: ToolDiffLine[] = [];
21
+ let oldLine = hunk.oldStart;
22
+ let newLine = hunk.newStart;
23
+
24
+ for (const raw of hunk.lines) {
25
+ if (raw.startsWith("\\")) {
26
+ continue;
27
+ }
28
+
29
+ const kind = kindOf(raw);
30
+ const text = raw.slice(1);
31
+
32
+ if (kind === "added") {
33
+ lines.push({ kind, newLine, text });
34
+ newLine += 1;
35
+ continue;
36
+ }
37
+
38
+ if (kind === "removed") {
39
+ lines.push({ kind, oldLine, text });
40
+ oldLine += 1;
41
+ continue;
42
+ }
43
+
44
+ lines.push({ kind, oldLine, newLine, text });
45
+ oldLine += 1;
46
+ newLine += 1;
47
+ }
48
+
49
+ return {
50
+ oldStart: hunk.oldStart,
51
+ oldLines: hunk.oldLines,
52
+ newStart: hunk.newStart,
53
+ newLines: hunk.newLines,
54
+ lines: DiffLines.attachEmphasis(lines),
55
+ };
56
+ }
57
+
58
+ function kindOf(line: string): ToolDiffLineKind {
59
+ if (line.startsWith("+")) {
60
+ return "added";
61
+ }
62
+
63
+ if (line.startsWith("-")) {
64
+ return "removed";
65
+ }
66
+
67
+ return "context";
68
+ }
69
+
70
+ export const DiffPatch = { fromUnified };
@@ -95,7 +95,7 @@ function highlightHunkLines(
95
95
  hunk: ToolDiffHunk,
96
96
  highlighter: DiffHighlighter
97
97
  ): readonly string[] {
98
- const highlighted = DiffLayout.mapSides(hunk, highlighter);
98
+ const highlighted = DiffLayout.mapSides(hunk.lines, highlighter);
99
99
  return hunk.lines.map((line, index) => highlighted[index] ?? line.text);
100
100
  }
101
101
 
@@ -8,6 +8,12 @@ import { basename, dirname, join } from "node:path";
8
8
 
9
9
  const POLL_MS = 1_000;
10
10
 
11
+ /**
12
+ * A burst coalesces into one event, and the read it triggers can land inside
13
+ * the burst — so look once more after it, or the last write waits on the poll.
14
+ */
15
+ const SETTLE_MS = 25;
16
+
11
17
  function signatureOf(path: string): string {
12
18
  const stats = statSync(path, { throwIfNoEntry: false });
13
19
  return stats === undefined
@@ -41,6 +47,13 @@ function follow(
41
47
  pollMs: number
42
48
  ): () => void {
43
49
  let watcher: FSWatcher | undefined;
50
+ let settle: ReturnType<typeof setTimeout> | undefined;
51
+ const fire = (): void => {
52
+ onEvent();
53
+ clearTimeout(settle);
54
+ settle = setTimeout(onEvent, SETTLE_MS);
55
+ settle.unref?.();
56
+ };
44
57
  const arm = (): void => {
45
58
  if (watcher !== undefined) {
46
59
  return;
@@ -48,7 +61,7 @@ function follow(
48
61
  try {
49
62
  watcher = watchFs(watched, { persistent: false }, (_event, changed) => {
50
63
  if (entry === undefined || changed === null || changed === entry) {
51
- onEvent();
64
+ fire();
52
65
  }
53
66
  });
54
67
  watcher.on("error", () => {
@@ -66,6 +79,7 @@ function follow(
66
79
  timer.unref?.();
67
80
  return (): void => {
68
81
  clearInterval(timer);
82
+ clearTimeout(settle);
69
83
  try {
70
84
  watcher?.close();
71
85
  } catch {}