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
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.10.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,7 +52,7 @@
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": {
@@ -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
  }
@@ -1,28 +1,36 @@
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";
6
+
7
+ import type { Surface } from "../../shared/Surface";
3
8
  import { buildSystemPrompt } from "./prompt";
4
9
 
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,
10
+ export default function (surface?: Surface): ExtensionFactory {
11
+ return (pi: ExtensionAPI): void => {
12
+ pi.on("before_agent_start", (event, ctx) => {
13
+ const {
18
14
  cwd,
19
- contextFiles: contextFiles ?? [],
20
- skillsBlock:
21
- skills && skills.length > 0 ? formatSkillsForPrompt(skills) : "",
22
- toolGuidelines: promptGuidelines ?? [],
15
+ contextFiles,
16
+ skills,
17
+ promptGuidelines,
23
18
  appendSystemPrompt,
24
19
  customPrompt,
25
- }),
26
- };
27
- });
20
+ } = event.systemPromptOptions;
21
+ return {
22
+ systemPrompt: buildSystemPrompt({
23
+ model: ctx.model,
24
+ cwd,
25
+ contextFiles: contextFiles ?? [],
26
+ skillsBlock:
27
+ skills && skills.length > 0 ? formatSkillsForPrompt(skills) : "",
28
+ toolGuidelines: promptGuidelines ?? [],
29
+ appendSystemPrompt,
30
+ customPrompt,
31
+ ...(surface === undefined ? {} : { surface }),
32
+ }),
33
+ };
34
+ });
35
+ };
28
36
  }
@@ -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,6 +60,7 @@ export function buildSystemPrompt(opts: BuildOptions): string {
57
60
  `- cwd: ${opts.cwd}`,
58
61
  `- os: ${opts.os ?? describeOs()}`,
59
62
  `- model: ${model}`,
63
+ ...(opts.surface ? [`- surface: ${opts.surface}`] : []),
60
64
  `- datetime: ${formatDatetime(new Date())}`,
61
65
  "</environment>",
62
66
  ].join("\n")
@@ -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
  }