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.
- package/bin/pim.ts +1 -1
- package/package.json +2 -2
- 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/system-prompt/index.ts +29 -21
- package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
- 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 +17 -1
- 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 +89 -14
- 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/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/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 +129 -2
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +157 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +90 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +69 -43
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +264 -29
- package/packages/server/src/probe.ts +61 -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/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
- package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
- package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {}
|