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
package/bin/pim.ts CHANGED
@@ -24,7 +24,7 @@ import { themeCliArgs } from "#tui/themes/themeCliArgs";
24
24
  // `_init` must stay first: it installs the runtime guard.
25
25
  const extensionFactories: readonly PimInlineExtension[] = [
26
26
  { name: "_init", factory: init },
27
- ...CoreExtensions.list,
27
+ ...CoreExtensions.list("terminal"),
28
28
  { name: "command-picker", factory: commandPicker },
29
29
  { name: "file-picker", factory: filePicker },
30
30
  { name: "footer", factory: footer },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pim-agent",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "A batteries-included distro of Pi, accessible in your terminal, browser and Telegram.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,11 +52,10 @@
52
52
  "check": "bun scripts/check.ts",
53
53
  "gen:brand": "bun scripts/brand/generate.ts",
54
54
  "gen:hero": "bun scripts/hero/generate.ts",
55
- "web:build": "cd packages/web && vite build",
55
+ "web:build": "cd packages/web && NODE_ENV=production vite build",
56
56
  "web:dev": "cd packages/web && vite"
57
57
  },
58
58
  "devDependencies": {
59
- "@fontsource/commit-mono": "^5.3.0",
60
59
  "@happy-dom/global-registrator": "^20.14.0",
61
60
  "@iconify-json/griddy-icons": "^1.2.0",
62
61
  "@iconify-json/solar": "^1.2.10",
@@ -7,6 +7,7 @@ import {
7
7
  ExtensionToggles,
8
8
  type PimExtensionName,
9
9
  } from "../shared/ExtensionToggles";
10
+ import type { Surface } from "../shared/Surface";
10
11
  import applyPatch from "./apply-patch/index";
11
12
  import bash from "./bash/index";
12
13
  import edit from "./edit/index";
@@ -26,23 +27,25 @@ export type PimInlineExtension = {
26
27
  };
27
28
 
28
29
  // Enumerated, never globbed: the published tarball must not depend on a directory scan.
29
- const list: readonly PimInlineExtension[] = [
30
- { name: "apply-patch", factory: applyPatch },
31
- { name: "bash", factory: bash },
32
- { name: "edit", factory: edit },
33
- { name: "glob", factory: glob },
34
- { name: "grep", factory: grep },
35
- { name: "read", factory: read },
36
- { name: "subagent", factory: subagent },
37
- { name: "system-prompt", factory: systemPrompt },
38
- { name: "todo", factory: todo },
39
- { name: "web-fetch", factory: webFetch },
40
- { name: "web-search", factory: webSearch },
41
- { name: "write", factory: write },
42
- ];
30
+ function list(surface?: Surface): readonly PimInlineExtension[] {
31
+ return [
32
+ { name: "apply-patch", factory: applyPatch },
33
+ { name: "bash", factory: bash },
34
+ { name: "edit", factory: edit },
35
+ { name: "glob", factory: glob },
36
+ { name: "grep", factory: grep },
37
+ { name: "read", factory: read },
38
+ { name: "subagent", factory: subagent },
39
+ { name: "system-prompt", factory: systemPrompt(surface) },
40
+ { name: "todo", factory: todo },
41
+ { name: "web-fetch", factory: webFetch },
42
+ { name: "web-search", factory: webSearch },
43
+ { name: "write", factory: write },
44
+ ];
45
+ }
43
46
 
44
- function gated(): InlineExtension[] {
45
- return list.map(({ name, factory }) => ({
47
+ function gated(surface?: Surface): InlineExtension[] {
48
+ return list(surface).map(({ name, factory }) => ({
46
49
  name,
47
50
  factory: ExtensionToggles.gate(name, factory),
48
51
  }));
@@ -3,11 +3,12 @@ import { Paths } from "../../shared/Paths";
3
3
  import { PatchSummary } from "../../shared/PatchSummary";
4
4
  import type { ToolViewInput } from "../../shared/Tools";
5
5
  import { DiffBlocks } from "../../view/DiffBlocks";
6
+ import { MovePath } from "../../view/MovePath";
6
7
  import type { Span, ToolIcon, ToolView, ViewBlock } from "../../view/ViewBlock";
7
8
  import type { ApplyEntry } from "./executor";
8
9
  import { type applyPatchSchema, prepareApplyPatchArguments } from "./schema";
9
10
 
10
- const ARROW = "➝";
11
+ const ARROW = MovePath.ARROW;
11
12
 
12
13
  export type ApplyPatchDetails = {
13
14
  readonly entries?: readonly ApplyEntry[];
@@ -152,65 +153,25 @@ function sectionBlock(entry: EntryView): ViewBlock {
152
153
  }
153
154
 
154
155
  function moveTitle(oldPath: string, newPath: string): readonly Span[] {
155
- const oldParts = oldPath.split("/");
156
- const newParts = newPath.split("/");
157
- let commonPrefix = 0;
158
-
159
- while (
160
- commonPrefix < oldParts.length &&
161
- commonPrefix < newParts.length &&
162
- oldParts[commonPrefix] === newParts[commonPrefix]
163
- ) {
164
- commonPrefix += 1;
165
- }
166
-
167
- let commonSuffix = 0;
168
- while (
169
- commonSuffix < oldParts.length - commonPrefix &&
170
- commonSuffix < newParts.length - commonPrefix &&
171
- oldParts[oldParts.length - commonSuffix - 1] ===
172
- newParts[newParts.length - commonSuffix - 1]
173
- ) {
174
- commonSuffix += 1;
175
- }
176
-
177
- const oldChanged = oldParts.slice(
178
- commonPrefix,
179
- oldParts.length - commonSuffix
180
- );
181
- const newChanged = newParts.slice(
182
- commonPrefix,
183
- newParts.length - commonSuffix
184
- );
185
-
186
- if (
187
- oldChanged.length > 0 &&
188
- newChanged.length > 0 &&
189
- (commonPrefix > 0 ||
190
- commonSuffix > 0 ||
191
- (oldParts.length === 1 && newParts.length === 1))
192
- ) {
193
- const prefix =
194
- commonPrefix > 0 ? `${oldParts.slice(0, commonPrefix).join("/")}/` : "";
195
- const suffix =
196
- commonSuffix > 0 ? `/${oldParts.slice(-commonSuffix).join("/")}` : "";
156
+ const folded = MovePath.fold(oldPath, newPath);
197
157
 
158
+ if (folded === undefined) {
198
159
  return [
199
- { text: prefix },
200
- { text: "{", tone: "dim" },
201
- { text: oldChanged.join("/"), tone: "dim", strike: true },
202
- { text: ` ${ARROW} `, tone: "dim" },
203
- { text: newChanged.join("/"), tone: "title" },
204
- { text: "}", tone: "dim" },
205
- { text: suffix, tone: "title" },
160
+ { text: oldPath, tone: "dim", strike: true },
161
+ { text: " " },
162
+ { text: ARROW, tone: "dim" },
163
+ { text: " " },
164
+ { text: newPath, tone: "title" },
206
165
  ];
207
166
  }
208
167
 
209
168
  return [
210
- { text: oldPath, tone: "dim", strike: true },
211
- { text: " " },
212
- { text: ARROW, tone: "dim" },
213
- { text: " " },
214
- { text: newPath, tone: "title" },
169
+ { text: folded.prefix },
170
+ { text: "{", tone: "dim" },
171
+ { text: folded.from, tone: "dim", strike: true },
172
+ { text: ` ${ARROW} `, tone: "dim" },
173
+ { text: folded.to, tone: "title" },
174
+ { text: "}", tone: "dim" },
175
+ { text: folded.suffix, tone: "title" },
215
176
  ];
216
177
  }
@@ -42,7 +42,8 @@ export default function (pi: ExtensionAPI): void {
42
42
  name: "bash",
43
43
  label: "bash",
44
44
  description:
45
- "Execute a bash command in the cwd. " +
45
+ "Execute a bash command in the cwd from <environment>. " +
46
+ "Never prefix `cd <cwd> &&`; pass paths relative to cwd or absolute. " +
46
47
  "Returns exit code, signal (if any), and stdout/stderr captured separately. " +
47
48
  "Stdout that is a png/jpeg/gif/webp is returned as a picture. " +
48
49
  "Prefer commands that emit only what you need; keep output as small as possible.",
@@ -14,6 +14,12 @@ type Reader = ReadableStreamDefaultReader<Uint8Array>;
14
14
 
15
15
  const activePids = new Set<number>();
16
16
 
17
+ const COMMAND_VAR = "PIM_BASH_COMMAND";
18
+ // argv is what `pkill -f`/`pgrep -f` match, so a command naming its own target would
19
+ // signal this shell before its target. The env hands it over out of argv's reach, and
20
+ // the unset keeps it out of every child's environment too.
21
+ const RUNNER = `__pim_command=$${COMMAND_VAR}; unset ${COMMAND_VAR}; eval "$__pim_command"`;
22
+
17
23
  export function killAllActiveBashGroups(sig: NodeJS.Signals = "SIGTERM"): void {
18
24
  for (const pid of activePids) {
19
25
  killGroup(pid, sig);
@@ -97,11 +103,11 @@ export async function runBashCommand(
97
103
 
98
104
  // setsid gives the tree its own process group (pgid == proc.pid) so the whole tree can be signalled.
99
105
  const proc = Bun.spawn({
100
- cmd: ["setsid", "bash", "-lc", command],
106
+ cmd: ["setsid", "bash", "-lc", RUNNER],
101
107
  cwd,
102
108
  stdout: "pipe",
103
109
  stderr: "pipe",
104
- env: { ...process.env },
110
+ env: { ...process.env, [COMMAND_VAR]: command },
105
111
  });
106
112
  if (proc.pid !== undefined) {
107
113
  activePids.add(proc.pid);
@@ -1,28 +1,51 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionFactory,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import { formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
3
- import { buildSystemPrompt } from "./prompt";
4
6
 
5
- export default function (pi: ExtensionAPI): void {
6
- pi.on("before_agent_start", (event, ctx) => {
7
- const {
8
- cwd,
9
- contextFiles,
10
- skills,
11
- promptGuidelines,
12
- appendSystemPrompt,
13
- customPrompt,
14
- } = event.systemPromptOptions;
15
- return {
16
- systemPrompt: buildSystemPrompt({
17
- model: ctx.model,
7
+ import type { Surface } from "../../shared/Surface";
8
+ import { buildSystemPrompt, formatDatetime } from "./prompt";
9
+
10
+ const DATETIME_MESSAGE_TYPE = "pim-datetime";
11
+
12
+ export default function (surface?: Surface): ExtensionFactory {
13
+ return (pi: ExtensionAPI): void => {
14
+ // On submit rather than in `before_agent_start`, which can only append
15
+ // behind the user's message.
16
+ pi.on("input", () => {
17
+ pi.sendMessage(
18
+ {
19
+ customType: DATETIME_MESSAGE_TYPE,
20
+ content: `<datetime>${formatDatetime(new Date())}</datetime>`,
21
+ display: false,
22
+ },
23
+ { triggerTurn: false }
24
+ );
25
+ return { action: "continue" };
26
+ });
27
+ pi.on("before_agent_start", (event, ctx) => {
28
+ const {
18
29
  cwd,
19
- contextFiles: contextFiles ?? [],
20
- skillsBlock:
21
- skills && skills.length > 0 ? formatSkillsForPrompt(skills) : "",
22
- toolGuidelines: promptGuidelines ?? [],
30
+ contextFiles,
31
+ skills,
32
+ promptGuidelines,
23
33
  appendSystemPrompt,
24
34
  customPrompt,
25
- }),
26
- };
27
- });
35
+ } = event.systemPromptOptions;
36
+ return {
37
+ systemPrompt: buildSystemPrompt({
38
+ model: ctx.model,
39
+ cwd,
40
+ contextFiles: contextFiles ?? [],
41
+ skillsBlock:
42
+ skills && skills.length > 0 ? formatSkillsForPrompt(skills) : "",
43
+ toolGuidelines: promptGuidelines ?? [],
44
+ appendSystemPrompt,
45
+ customPrompt,
46
+ ...(surface === undefined ? {} : { surface }),
47
+ }),
48
+ };
49
+ });
50
+ };
28
51
  }
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import type { Surface } from "../../shared/Surface";
4
+
3
5
  type BuildOptions = {
4
6
  readonly model?: ExtensionContext["model"];
5
7
  readonly cwd: string;
@@ -12,6 +14,7 @@ type BuildOptions = {
12
14
  readonly appendSystemPrompt?: string;
13
15
  readonly customPrompt?: string;
14
16
  readonly os?: string;
17
+ readonly surface?: Surface;
15
18
  };
16
19
 
17
20
  type RunCommand = (cmd: ReadonlyArray<string>) => string | undefined;
@@ -40,7 +43,7 @@ export function buildSystemPrompt(opts: BuildOptions): string {
40
43
  sections.push(
41
44
  [
42
45
  "<system_instructions>",
43
- "You are pim (Pi IMproved), a Bun-native, opinionated extension pack for the [pi agent harness](https://pi.dev/).",
46
+ "You are Pim (Pi IMproved), a batteries-included agent built on the Pi harness.",
44
47
  ...opts.toolGuidelines.map((g) => `- ${g}`),
45
48
  ...dynamicGuidelines().map((g) => `- ${g}`),
46
49
  "</system_instructions>",
@@ -57,7 +60,7 @@ export function buildSystemPrompt(opts: BuildOptions): string {
57
60
  `- cwd: ${opts.cwd}`,
58
61
  `- os: ${opts.os ?? describeOs()}`,
59
62
  `- model: ${model}`,
60
- `- datetime: ${formatDatetime(new Date())}`,
63
+ ...(opts.surface ? [`- surface: ${opts.surface}`] : []),
61
64
  "</environment>",
62
65
  ].join("\n")
63
66
  );
@@ -206,7 +209,7 @@ function unquoteValue(value: string): string {
206
209
  return quote === "'" ? unquoted : unquoted.replace(/\\(["\\$`])/g, "$1");
207
210
  }
208
211
 
209
- function formatDatetime(d: Date): string {
212
+ export function formatDatetime(d: Date): string {
210
213
  const pad = (n: number): string => String(n).padStart(2, "0");
211
214
  const offsetMinutes = -d.getTimezoneOffset();
212
215
  const sign = offsetMinutes >= 0 ? "+" : "-";
@@ -1,4 +1,7 @@
1
1
  import { type AgentSession, loadSkills } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
2
5
 
3
6
  import { loadRelative } from "./catalog";
4
7
  import { rankCommands } from "./commandRanker";
@@ -15,6 +18,25 @@ export type PickerServiceDeps = {
15
18
 
16
19
  const DEFAULT_LIMIT = 50;
17
20
 
21
+ /**
22
+ * The `.agents/skills` roots pi reads and `loadSkills` does not: every one from
23
+ * the cwd up to the repository root, plus the user's own.
24
+ */
25
+ function agentsSkillDirs(cwd: string): string[] {
26
+ const dirs: string[] = [];
27
+ let dir = resolve(cwd);
28
+ for (;;) {
29
+ dirs.push(join(dir, ".agents", "skills"));
30
+ const parent = dirname(dir);
31
+ if (existsSync(join(dir, ".git")) || parent === dir) {
32
+ break;
33
+ }
34
+ dir = parent;
35
+ }
36
+ dirs.push(join(process.env.HOME ?? homedir(), ".agents", "skills"));
37
+ return dirs;
38
+ }
39
+
18
40
  /** Answers one session's picker queries against the machine the agent runs on. */
19
41
  export class PickerService {
20
42
  private readonly deps: PickerServiceDeps;
@@ -81,7 +103,7 @@ export class PickerService {
81
103
  const { skills } = loadSkills({
82
104
  cwd,
83
105
  agentDir: this.deps.agentDir,
84
- skillPaths: [],
106
+ skillPaths: agentsSkillDirs(cwd),
85
107
  includeDefaults: true,
86
108
  });
87
109
  return skills.map((skill) => ({
@@ -7,12 +7,12 @@ export type PickerToken = {
7
7
  };
8
8
 
9
9
  export const AT_PREFIX = /(?:^|\s)@(\S*)$/;
10
- export const SLASH_PREFIX = /^\/(\S*)$/;
10
+ export const SLASH_PREFIX = /(?:^|\s)\/(\S*)$/;
11
11
 
12
- /** Offset of the sigil in an `AT_PREFIX` match, which also swallows the whitespace before it. */
12
+ /** Offset of the sigil in a match that also swallows the whitespace before it. */
13
13
  export function sigilOffset(match: RegExpMatchArray): number {
14
14
  const matched = match[0] ?? "";
15
- return (match.index ?? 0) + (matched.startsWith("@") ? 0 : 1);
15
+ return (match.index ?? 0) + (/^\s/.test(matched) ? 1 : 0);
16
16
  }
17
17
 
18
18
  export function isDirectoryItem(item: { readonly label: string }): boolean {
@@ -28,7 +28,11 @@ export function activeToken(
28
28
 
29
29
  const slash = SLASH_PREFIX.exec(beforeCaret);
30
30
  if (slash) {
31
- return { kind: "command", query: slash[1] ?? "", start: lineStart };
31
+ return {
32
+ kind: "command",
33
+ query: slash[1] ?? "",
34
+ start: lineStart + sigilOffset(slash),
35
+ };
32
36
  }
33
37
 
34
38
  const at = AT_PREFIX.exec(beforeCaret);
@@ -59,9 +63,12 @@ export function applyCompletion(
59
63
  token: PickerToken,
60
64
  item: { readonly value: string; readonly label: string }
61
65
  ): Completion {
62
- const inserted = token.kind === "file" ? `@${item.value}` : `${item.value} `;
63
66
  const head = text.slice(0, token.start);
64
67
  const tail = text.slice(caret);
68
+ const inserted =
69
+ token.kind === "file"
70
+ ? `@${item.value}`
71
+ : `${item.value}${/^\s/.test(tail) ? "" : " "}`;
65
72
  return {
66
73
  text: `${head}${inserted}${tail}`,
67
74
  caret: head.length + inserted.length,
@@ -3,8 +3,9 @@ import type { BunFile } from "bun";
3
3
  import type { FileEntry, SessionHeader } from "@earendil-works/pi-coding-agent";
4
4
  import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { Attachments } from "../attachments/Attachments";
7
- import { MessageText } from "./MessageText";
6
+ import { SessionDigest, type Durable } from "./SessionDigest";
7
+
8
+ export type { SessionDigest } from "./SessionDigest";
8
9
 
9
10
  async function ifPresent<T>(read: () => Promise<T>, absent: T): Promise<T> {
10
11
  try {
@@ -28,8 +29,6 @@ type Cursor = {
28
29
  readonly seq: number;
29
30
  };
30
31
 
31
- const PROBE_LINES = 16;
32
-
33
32
  const PROBE_LIMIT = 16 * 1024 * 1024;
34
33
 
35
34
  const PROBE_CHUNK = 8 * 1024;
@@ -37,19 +36,6 @@ const PROBE_CHUNK_MAX = 256 * 1024;
37
36
 
38
37
  const EMPTY = new Uint8Array();
39
38
 
40
- const TAIL_BYTES = 64 * 1024;
41
- const TAIL_LINES = 10;
42
-
43
- const TITLE_LIMIT = 120;
44
-
45
- /** What the session catalogue shows for one file without opening a session. */
46
- export type SessionDigest = {
47
- /** The first user message, trimmed; absent when the session has none yet. */
48
- readonly title?: string;
49
- /** When the agent last wrote; it moves with an in-flight turn, so read it before starting one. */
50
- readonly settledAt?: number;
51
- };
52
-
53
39
  function isHeader(entry: FileEntry): entry is SessionHeader {
54
40
  return entry.type === "session";
55
41
  }
@@ -112,17 +98,23 @@ export class EventLog {
112
98
  return this.cursor.seq;
113
99
  }
114
100
 
115
- /** Title and settle time for the catalogue, read from the two ends of the file. */
101
+ /**
102
+ * The name pi's own `/name` writes: the last `session_info` entry wins, and an
103
+ * empty one clears it. Appended rather than replaced, so it is found by reading
104
+ * the whole file — the one unbounded read the catalogue makes, and the only
105
+ * reason `SessionDigest` takes a whole file rather than a bounded head and tail.
106
+ */
107
+ public async name(): Promise<string | undefined> {
108
+ const body = await durableBytes(Bun.file(this.path));
109
+ return body === undefined ? undefined : SessionDigest.nameOf(body);
110
+ }
111
+
112
+ /** Title and settle time for the catalogue, without opening a session. */
116
113
  public async digest(): Promise<SessionDigest> {
117
- const file = Bun.file(this.path);
118
- const [title, settledAt] = await Promise.all([
119
- firstUserMessage(file),
120
- settleTime(file),
121
- ]);
122
- return {
123
- ...(title === undefined ? {} : { title }),
124
- ...(settledAt === undefined ? {} : { settledAt }),
125
- };
114
+ const body = await durableBytes(Bun.file(this.path));
115
+ return body === undefined
116
+ ? {}
117
+ : SessionDigest.of(SessionDigest.partsOf(body));
126
118
  }
127
119
  }
128
120
 
@@ -158,56 +150,7 @@ async function* headLines(
158
150
  }
159
151
  }
160
152
 
161
- async function settleTime(file: BunFile): Promise<number | undefined> {
162
- const near = Math.max(0, file.size - TAIL_BYTES);
163
- const lines = await tailLines(file, near);
164
- const found = lastAgentTime(lines);
165
- if (found !== undefined || near === 0 || lines.length >= TAIL_LINES) {
166
- return found;
167
- }
168
- return lastAgentTime(await tailLines(file, 0));
169
- }
170
-
171
- async function tailLines(file: BunFile, from: number): Promise<string[]> {
172
- const text = await ifPresent(() => file.slice(from).text(), "");
173
- const lines = text.split("\n");
174
- lines.pop();
175
- if (from > 0) {
176
- lines.shift();
177
- }
178
- return lines.slice(-TAIL_LINES).reverse();
179
- }
180
-
181
- function lastAgentTime(lines: readonly string[]): number | undefined {
182
- for (const line of lines) {
183
- const entry = parseSessionEntries(line)[0];
184
- if (entry?.type !== "message") {
185
- continue;
186
- }
187
- const role = entry.message.role;
188
- if (role === "assistant" || role === "toolResult") {
189
- const at = Date.parse(entry.timestamp);
190
- return Number.isNaN(at) ? undefined : at;
191
- }
192
- }
193
- return undefined;
194
- }
195
-
196
- async function firstUserMessage(file: BunFile): Promise<string | undefined> {
197
- for await (const line of headLines(file, PROBE_LINES)) {
198
- const entry = parseSessionEntries(line)[0];
199
- if (entry?.type !== "message" || entry.message.role !== "user") {
200
- continue;
201
- }
202
- const said = Attachments.parse(MessageText.textOf(entry.message.content));
203
- const text =
204
- said.text.trim() ||
205
- said.files.map((file) => Attachments.nameOf(file.path)).join(", ");
206
- if (text !== "") {
207
- return text.length > TITLE_LIMIT
208
- ? `${text.slice(0, TITLE_LIMIT).trimEnd()}…`
209
- : text;
210
- }
211
- }
212
- return undefined;
153
+ async function durableBytes(file: BunFile): Promise<Durable | undefined> {
154
+ const read = await ifPresent(() => file.bytes(), EMPTY);
155
+ return SessionDigest.durable(read);
213
156
  }