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
@@ -0,0 +1,221 @@
1
+ import { join } from "node:path";
2
+
3
+ import { FileWatch } from "./FileWatch";
4
+ import { Git, type GitOutcome, type GitState } from "./Git";
5
+
6
+ export type GitListener = (state: GitState) => void;
7
+
8
+ /**
9
+ * What one operation under the lock says, and what it made where it made
10
+ * anything: a commit has a sha to hand back, a checkout has only its outcome.
11
+ */
12
+ export type GitRun<T = never> = GitOutcome & { readonly value?: T };
13
+
14
+ export type GitMonitorDeps = {
15
+ /** How long a fetch stands before another is worth its round trip. */
16
+ readonly fetchTtlMs?: number;
17
+ /** How often a watched repository is read for the edits `.git` never hears about. */
18
+ readonly pollMs?: number;
19
+ readonly status?: (cwd: string) => Promise<GitState>;
20
+ readonly fetch?: (cwd: string) => Promise<GitOutcome>;
21
+ };
22
+
23
+ type Entry = {
24
+ readonly cwd: string;
25
+ readonly listeners: Set<GitListener>;
26
+ state: GitState;
27
+ stop: (() => void) | undefined;
28
+ inFlight: Promise<GitState> | undefined;
29
+ pending: boolean;
30
+ fetchedAt: number;
31
+ locked: boolean;
32
+ /** How long the last read took, and when it ended: what the poll paces itself by. */
33
+ readMs: number;
34
+ readAt: number;
35
+ };
36
+
37
+ /** A checkout writes half the repository; one read has to stand for the burst. */
38
+ const DEBOUNCE_MS = 200;
39
+
40
+ const FETCH_TTL_MS = 30_000;
41
+
42
+ /** A worktree edit touches nothing under `.git`, so the watch alone would never see it. */
43
+ const POLL_MS = 2_000;
44
+
45
+ /** A repository too big to read in a tick is read this many times its own cost apart. */
46
+ const BACKOFF = 4;
47
+
48
+ function same(a: GitState, b: GitState): boolean {
49
+ return (
50
+ a.branch === b.branch &&
51
+ a.dirtyCount === b.dirtyCount &&
52
+ a.ahead === b.ahead &&
53
+ a.behind === b.behind &&
54
+ a.revision === b.revision
55
+ );
56
+ }
57
+
58
+ /**
59
+ * One reader of one repository, however many sessions share it: it watches
60
+ * `.git` so a commit anywhere reaches every surface, coalesces the burst a
61
+ * checkout makes into a single read, and serialises the operations that write,
62
+ * which cannot run two at a time over one index.
63
+ */
64
+ export class GitMonitor {
65
+ private readonly entries = new Map<string, Entry>();
66
+ private readonly deps: GitMonitorDeps;
67
+
68
+ public constructor(deps: GitMonitorDeps = {}) {
69
+ this.deps = deps;
70
+ }
71
+
72
+ /** Follows `cwd` until the returned stop is called; the listener hears only changes, after a read of its own. */
73
+ public watch(cwd: string, listener: GitListener): () => void {
74
+ const entry = this.entryOf(cwd);
75
+ entry.listeners.add(listener);
76
+ entry.stop ??= this.follow(entry);
77
+ void this.read(entry, false);
78
+ return () => {
79
+ entry.listeners.delete(listener);
80
+ if (entry.listeners.size === 0) {
81
+ entry.stop?.();
82
+ entry.stop = undefined;
83
+ }
84
+ };
85
+ }
86
+
87
+ /** The last state read, without waiting: a caller painting a frame takes what is known now. */
88
+ public stateOf(cwd: string): GitState {
89
+ return this.entries.get(cwd)?.state ?? Git.EMPTY;
90
+ }
91
+
92
+ /** Re-reads now; `fetch` also asks the remote, which is what ahead and behind are counted against. */
93
+ public async refresh(
94
+ cwd: string,
95
+ options: { readonly fetch?: boolean } = {}
96
+ ): Promise<GitState> {
97
+ const entry = this.entryOf(cwd);
98
+ const ttl = this.deps.fetchTtlMs ?? FETCH_TTL_MS;
99
+ if (
100
+ options.fetch === true &&
101
+ !entry.locked &&
102
+ Date.now() - entry.fetchedAt > ttl
103
+ ) {
104
+ entry.fetchedAt = Date.now();
105
+ await (this.deps.fetch ?? Git.fetch)(cwd).catch(() => undefined);
106
+ }
107
+ return await this.read(entry);
108
+ }
109
+
110
+ /** Runs one writing operation over `cwd`, refusing a second while it lasts and re-reading once it ends. */
111
+ public async run<T = never>(
112
+ cwd: string,
113
+ operation: () => Promise<GitRun<T>>
114
+ ): Promise<GitRun<T>> {
115
+ const entry = this.entryOf(cwd);
116
+ if (entry.locked) {
117
+ return {
118
+ ok: false,
119
+ error: "another git operation is already running in this directory",
120
+ };
121
+ }
122
+ entry.locked = true;
123
+ try {
124
+ return await operation();
125
+ } finally {
126
+ entry.locked = false;
127
+ await this.read(entry);
128
+ }
129
+ }
130
+
131
+ public dispose(): void {
132
+ for (const entry of this.entries.values()) {
133
+ entry.stop?.();
134
+ }
135
+ this.entries.clear();
136
+ }
137
+
138
+ private entryOf(cwd: string): Entry {
139
+ const found = this.entries.get(cwd);
140
+ if (found) {
141
+ return found;
142
+ }
143
+ const entry: Entry = {
144
+ cwd,
145
+ listeners: new Set(),
146
+ state: Git.EMPTY,
147
+ stop: undefined,
148
+ inFlight: undefined,
149
+ pending: false,
150
+ fetchedAt: 0,
151
+ locked: false,
152
+ readMs: 0,
153
+ readAt: 0,
154
+ };
155
+ this.entries.set(cwd, entry);
156
+ return entry;
157
+ }
158
+
159
+ private follow(entry: Entry): () => void {
160
+ let timer: ReturnType<typeof setTimeout> | undefined;
161
+ const stop = FileWatch.directory(join(entry.cwd, ".git"), () => {
162
+ clearTimeout(timer);
163
+ timer = setTimeout(() => {
164
+ void this.read(entry);
165
+ }, DEBOUNCE_MS);
166
+ timer.unref?.();
167
+ });
168
+ // `false`: a read already running is fresh enough for a tick, and a slow
169
+ // status must never queue another behind itself.
170
+ const poll = setInterval(() => {
171
+ if (Date.now() - entry.readAt < entry.readMs * BACKOFF) {
172
+ return;
173
+ }
174
+ void this.read(entry, false);
175
+ }, this.deps.pollMs ?? POLL_MS);
176
+ poll.unref?.();
177
+ return () => {
178
+ clearTimeout(timer);
179
+ clearInterval(poll);
180
+ stop();
181
+ };
182
+ }
183
+
184
+ /** `restart` re-reads behind a read already running: a change cannot be answered by a read that predates it, a new subscriber can. */
185
+ private read(entry: Entry, restart = true): Promise<GitState> {
186
+ if (entry.inFlight) {
187
+ entry.pending ||= restart;
188
+ return entry.inFlight;
189
+ }
190
+ const run = (async (): Promise<GitState> => {
191
+ const started = Date.now();
192
+ try {
193
+ let next = entry.state;
194
+ do {
195
+ entry.pending = false;
196
+ next = await (this.deps.status ?? Git.fetchStatus)(entry.cwd);
197
+ } while (entry.pending);
198
+ this.settle(entry, next);
199
+ return next;
200
+ } catch {
201
+ return entry.state;
202
+ } finally {
203
+ entry.inFlight = undefined;
204
+ entry.readAt = Date.now();
205
+ entry.readMs = entry.readAt - started;
206
+ }
207
+ })();
208
+ entry.inFlight = run;
209
+ return run;
210
+ }
211
+
212
+ private settle(entry: Entry, next: GitState): void {
213
+ if (same(entry.state, next)) {
214
+ return;
215
+ }
216
+ entry.state = next;
217
+ for (const listener of entry.listeners) {
218
+ listener(next);
219
+ }
220
+ }
221
+ }
@@ -27,4 +27,57 @@ function distance(left: string, right: string): number {
27
27
  return previous[right.length] ?? 0;
28
28
  }
29
29
 
30
- export const Levenshtein = { distance };
30
+ /**
31
+ * Damerau–Levenshtein, where a transposition is one edit rather than two, and
32
+ * a pair that cannot reach `max` answers `max + 1` without finishing the
33
+ * matrix — the shape a vocabulary scan wants, since almost every term it asks
34
+ * about is nowhere near.
35
+ */
36
+ function damerau(left: string, right: string, max: number): number {
37
+ const over = max + 1;
38
+ if (left === right) {
39
+ return 0;
40
+ }
41
+ if (Math.abs(left.length - right.length) > max) {
42
+ return over;
43
+ }
44
+
45
+ const width = right.length + 1;
46
+ let before = Array.from({ length: width }, () => 0);
47
+ let previous = Array.from({ length: width }, (_, index) => index);
48
+ let current = Array.from({ length: width }, () => 0);
49
+
50
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
51
+ current[0] = leftIndex;
52
+ let best = leftIndex;
53
+
54
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
55
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
56
+ let value = Math.min(
57
+ previous[rightIndex]! + 1,
58
+ current[rightIndex - 1]! + 1,
59
+ previous[rightIndex - 1]! + cost
60
+ );
61
+ if (
62
+ leftIndex > 1 &&
63
+ rightIndex > 1 &&
64
+ left[leftIndex - 1] === right[rightIndex - 2] &&
65
+ left[leftIndex - 2] === right[rightIndex - 1]
66
+ ) {
67
+ value = Math.min(value, before[rightIndex - 2]! + 1);
68
+ }
69
+ current[rightIndex] = value;
70
+ best = Math.min(best, value);
71
+ }
72
+
73
+ if (best > max) {
74
+ return over;
75
+ }
76
+ [before, previous, current] = [previous, current, before];
77
+ }
78
+
79
+ const found = previous[right.length]!;
80
+ return found > max ? over : found;
81
+ }
82
+
83
+ export const Levenshtein = { distance, damerau };
@@ -1,12 +1,17 @@
1
1
  export type ProcOptions = {
2
2
  readonly cwd?: string | undefined;
3
3
  readonly stdout?: "pipe" | "inherit";
4
+ /** Merged over the process environment; a key set to `undefined` is removed from it. */
5
+ readonly env?: Readonly<Record<string, string | undefined>>;
6
+ /** Kills the child once it elapses, answering `timedOut`. */
7
+ readonly timeoutMs?: number;
4
8
  };
5
9
 
6
10
  export type ProcResult = {
7
11
  readonly code: number;
8
12
  readonly stdout: string;
9
13
  readonly stderr: string;
14
+ readonly timedOut: boolean;
10
15
  };
11
16
 
12
17
  async function run(
@@ -17,14 +22,29 @@ async function run(
17
22
  cwd: options.cwd,
18
23
  stdout: options.stdout ?? "pipe",
19
24
  stderr: "pipe",
25
+ // `undefined` is Bun's own "inherit this process's environment".
26
+ env:
27
+ options.env === undefined
28
+ ? undefined
29
+ : { ...process.env, ...options.env },
20
30
  });
31
+ let timedOut = false;
32
+ const timer =
33
+ options.timeoutMs === undefined
34
+ ? undefined
35
+ : setTimeout(() => {
36
+ timedOut = true;
37
+ proc.kill();
38
+ }, options.timeoutMs);
21
39
  const [stdout, stderr] = await Promise.all([
22
40
  proc.stdout instanceof ReadableStream
23
41
  ? new Response(proc.stdout).text()
24
42
  : "",
25
43
  new Response(proc.stderr).text(),
26
44
  ]);
27
- return { code: await proc.exited, stdout, stderr };
45
+ const code = await proc.exited;
46
+ clearTimeout(timer);
47
+ return { code, stdout, stderr, timedOut };
28
48
  }
29
49
 
30
50
  export const Proc = { run };