@gavin-hu/my-pi-agent 0.15.0 → 0.16.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 (106) hide show
  1. package/README.md +12 -11
  2. package/README.zh-CN.md +9 -7
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +83 -8
  26. package/extensions/file-browse/client.ts +3 -1
  27. package/extensions/file-browse/config.ts +23 -0
  28. package/extensions/file-browse/files.ts +183 -48
  29. package/extensions/file-browse/fuzzy.ts +65 -11
  30. package/extensions/file-browse/html.ts +78 -133
  31. package/extensions/file-browse/index.ts +17 -2
  32. package/extensions/file-browse/live-client.ts +158 -0
  33. package/extensions/file-browse/live.ts +256 -0
  34. package/extensions/file-browse/markdown.ts +3 -10
  35. package/extensions/file-browse/raw-html.ts +3 -10
  36. package/extensions/file-browse/router.ts +33 -8
  37. package/extensions/file-browse/server.ts +78 -13
  38. package/extensions/file-browse/styles.ts +124 -0
  39. package/extensions/file-open/README.md +1 -2
  40. package/extensions/{document → file-read}/README.md +72 -56
  41. package/extensions/{document → file-read}/config.ts +12 -9
  42. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  43. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  44. package/extensions/{document → file-read}/extract/file.ts +9 -9
  45. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  46. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  47. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  48. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  49. package/extensions/{document → file-read}/formats.ts +11 -11
  50. package/extensions/file-read/index.ts +17 -0
  51. package/extensions/{document → file-read}/paging.ts +15 -13
  52. package/extensions/{document → file-read}/render.ts +17 -13
  53. package/extensions/{document → file-read}/schema.ts +7 -7
  54. package/extensions/{document → file-read}/tool.ts +36 -28
  55. package/extensions/goal/tools.ts +10 -14
  56. package/extensions/job/commands.ts +32 -46
  57. package/extensions/job/runtime.ts +6 -8
  58. package/extensions/job/tools.ts +7 -26
  59. package/extensions/memory/commands.ts +30 -44
  60. package/extensions/memory/tools.ts +6 -14
  61. package/extensions/plan/tools/enter.ts +6 -7
  62. package/extensions/plan/tools/exit.ts +6 -7
  63. package/extensions/plan/tools/steps.ts +6 -7
  64. package/extensions/plan/tools/write.ts +8 -9
  65. package/extensions/plan/web-router.ts +2 -12
  66. package/extensions/plan/web.ts +30 -15
  67. package/extensions/question/tools.ts +5 -4
  68. package/extensions/rewind/checklist.ts +10 -3
  69. package/extensions/rewind/git.ts +27 -14
  70. package/extensions/rewind/hunk-picker.ts +1 -1
  71. package/extensions/rewind/path-picker.ts +1 -1
  72. package/extensions/rewind/review-tui.ts +3 -3
  73. package/extensions/rewind/snapshot.ts +13 -4
  74. package/extensions/subagent/background-tui.ts +1 -1
  75. package/extensions/subagent/background.ts +1 -7
  76. package/extensions/todo/tools.ts +10 -14
  77. package/extensions/watch/README.md +5 -5
  78. package/extensions/watch/commands.ts +33 -47
  79. package/extensions/watch/runtime.ts +5 -7
  80. package/extensions/watch/tools.ts +7 -18
  81. package/extensions/web-access/README.md +3 -3
  82. package/extensions/web-access/search/search.ts +9 -18
  83. package/extensions/wechat/timer.ts +9 -30
  84. package/extensions/wechat/tools.ts +4 -7
  85. package/extensions/worktree/status.ts +14 -9
  86. package/lib/README.md +12 -8
  87. package/lib/escape.ts +15 -2
  88. package/lib/git/read.ts +20 -0
  89. package/lib/http/headers.ts +20 -0
  90. package/lib/http/server.ts +22 -4
  91. package/lib/pdf.ts +2 -2
  92. package/lib/pool.ts +62 -0
  93. package/lib/requirements.ts +2 -2
  94. package/lib/screen-session.ts +85 -0
  95. package/lib/timer.ts +61 -0
  96. package/lib/tui/README.md +3 -3
  97. package/lib/tui/columns.ts +33 -8
  98. package/lib/tui/dock-screen.ts +12 -4
  99. package/lib/tui/rows.ts +15 -6
  100. package/lib/tui/rules.ts +4 -8
  101. package/lib/tui/tool.ts +55 -2
  102. package/package.json +8 -4
  103. package/skills/changelog/SKILL.md +136 -0
  104. package/extensions/document/index.ts +0 -17
  105. /package/extensions/{document → file-read}/deadline.ts +0 -0
  106. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -1,14 +1,14 @@
1
1
  /**
2
- * The supported document formats, in one table.
2
+ * The supported formats, in one table.
3
3
  *
4
4
  * The tool handler, the config defaults, the `formats` merge, and the
5
- * unsupported-file error all derive from `FORMATS`, so adding a format is a
6
- * local change: one extractor module plus one entry here. See the README's
7
- * "Adding a format" section.
5
+ * unsupported-file error all derive from `FORMATS`, so a format is a local
6
+ * change: one extractor module plus one entry here. See the README's "Adding a
7
+ * format" section.
8
8
  */
9
9
 
10
10
  import { extname } from "node:path";
11
- import type { DocExtractor } from "./extract/file.ts";
11
+ import type { FormatExtractor } from "./extract/file.ts";
12
12
  import { extractDocText } from "./extract/doc.ts";
13
13
  import { extractDocxText } from "./extract/docx.ts";
14
14
  import { extractOdtText } from "./extract/odt.ts";
@@ -16,17 +16,17 @@ import { extractPdfDocument } from "./extract/pdf.ts";
16
16
  import { extractRtfText } from "./extract/rtf.ts";
17
17
  import { extractXlsxText } from "./extract/xlsx.ts";
18
18
 
19
- export interface DocumentFormat {
20
- /** Stable id, also the `document.formats` key in `bytefly.json` and the output `format`. */
19
+ export interface ReadFormat {
20
+ /** Stable id, also the `file-read.formats` key in `bytefly.json` and the output `format`. */
21
21
  id: "pdf" | "docx" | "doc" | "odt" | "rtf" | "xlsx";
22
22
  /** Human label for messages. */
23
23
  label: string;
24
24
  /** Lower-case extensions, with the dot, that select this format. */
25
25
  extensions: readonly string[];
26
- /** Enabled unless `document.formats[id]` in `bytefly.json` says otherwise. */
26
+ /** Enabled unless `file-read.formats[id]` in `bytefly.json` says otherwise. */
27
27
  defaultEnabled: boolean;
28
28
  /** Extractor; runs the format's parser and throws a readable error when it fails. */
29
- extract: DocExtractor;
29
+ extract: FormatExtractor;
30
30
  }
31
31
 
32
32
  export const FORMATS = [
@@ -36,7 +36,7 @@ export const FORMATS = [
36
36
  { id: "odt", label: "ODT", extensions: [".odt"], defaultEnabled: true, extract: extractOdtText },
37
37
  { id: "rtf", label: "RTF", extensions: [".rtf"], defaultEnabled: true, extract: extractRtfText },
38
38
  { id: "xlsx", label: "XLSX", extensions: [".xlsx"], defaultEnabled: true, extract: extractXlsxText },
39
- ] as const satisfies readonly DocumentFormat[];
39
+ ] as const satisfies readonly ReadFormat[];
40
40
 
41
41
  export type FormatId = (typeof FORMATS)[number]["id"];
42
42
 
@@ -46,7 +46,7 @@ export function supportedExtensions(): string[] {
46
46
  }
47
47
 
48
48
  /** The format selected by a path's extension, or undefined when unsupported. */
49
- export function detectFormat(path: string): DocumentFormat | undefined {
49
+ export function detectFormat(path: string): ReadFormat | undefined {
50
50
  const ext = extname(path).toLowerCase();
51
51
  return FORMATS.find((format) => (format.extensions as readonly string[]).includes(ext));
52
52
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The `file-read` extension entrypoint.
3
+ *
4
+ * Registers `read_doc`, which extracts text from a local document or workbook
5
+ * under the effective working root using in-process parsers. The factory only
6
+ * registers; a parser library loads lazily on the first read of its format, so
7
+ * the extension costs nothing at startup.
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { isExtensionEnabled } from "../../lib/env.ts";
12
+ import { registerReadTool } from "./tool.ts";
13
+
14
+ export default function fileRead(pi: ExtensionAPI): void {
15
+ if (!isExtensionEnabled("file-read")) return;
16
+ registerReadTool(pi);
17
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Model-facing text formatting and slicing for `read_doc`.
3
3
  *
4
- * `formatDoc` slices extracted text by code point from `startIndex`, so CJK and
4
+ * `formatPage` slices extracted text by code point from `startIndex`, so CJK and
5
5
  * emoji stay well-formed, and names the offset of the next chunk. The body is
6
6
  * the clean slice; the text adds a header and any truncation note.
7
7
  */
@@ -9,7 +9,9 @@
9
9
  import { formatCount } from "../../lib/format.ts";
10
10
  import type { FormatId } from "./formats.ts";
11
11
 
12
- export interface DocPageInput {
12
+ export interface PageInput {
13
+ /** The tool that produced this page; named in the truncation note. */
14
+ tool: string;
13
15
  path: string;
14
16
  format: FormatId;
15
17
  bytes: number;
@@ -18,8 +20,8 @@ export interface DocPageInput {
18
20
  maxChars: number;
19
21
  }
20
22
 
21
- export interface FormattedDoc {
22
- /** The requested slice of the document text. */
23
+ export interface FormattedPage {
24
+ /** The requested slice of the extracted text. */
23
25
  body: string;
24
26
  /** Header, body, and any truncation note. */
25
27
  text: string;
@@ -30,13 +32,13 @@ export interface FormattedDoc {
30
32
  chars: number;
31
33
  }
32
34
 
33
- export function formatDoc(input: DocPageInput): FormattedDoc {
35
+ export function formatPage(input: PageInput): FormattedPage {
34
36
  const points = Array.from(input.text);
35
37
  const total = points.length;
36
38
  const header = `Path: ${input.path}\nFormat: ${input.format.toUpperCase()}\nSize: ${input.bytes} bytes`;
37
39
 
38
40
  if (total === 0) {
39
- const text = `${header}\n\n(no extractable text; the document may be scanned or image-only)`;
41
+ const text = `${header}\n\n(no extractable text; the file may be empty or contain no readable text)`;
40
42
  return { body: "", text, truncated: false, nextIndex: input.startIndex, chars: 0 };
41
43
  }
42
44
 
@@ -52,14 +54,14 @@ export function formatDoc(input: DocPageInput): FormattedDoc {
52
54
 
53
55
  let text = `${header}\n\n${body}`;
54
56
  if (truncated) {
55
- text += `\n\n… (truncated at ${nextIndex} of ${total} characters; call read_doc again with startIndex=${nextIndex})`;
57
+ text += `\n\n… (truncated at ${nextIndex} of ${total} characters; call ${input.tool} again with startIndex=${nextIndex})`;
56
58
  }
57
59
 
58
60
  return { body, text, truncated, nextIndex, chars: total };
59
61
  }
60
62
 
61
- /** Inputs for `summarizeDoc`, the fields the transcript summary reads. */
62
- export interface DocSummaryInput {
63
+ /** Inputs for `summarizeRead`, the fields the transcript summary reads. */
64
+ export interface SummaryInput {
63
65
  format: FormatId;
64
66
  /** Total characters in the extracted text. */
65
67
  chars: number;
@@ -69,7 +71,7 @@ export interface DocSummaryInput {
69
71
  }
70
72
 
71
73
  /** Theme-free transcript summary: the format, the range detail, and any note. */
72
- export interface DocSummary {
74
+ export interface PageSummary {
73
75
  /** Uppercase format id, e.g. `PDF`. */
74
76
  format: string;
75
77
  /** Range detail, e.g. `chars 1–40,000 of 42,478`. */
@@ -81,13 +83,13 @@ export interface DocSummary {
81
83
  }
82
84
 
83
85
  /**
84
- * Describe a `read_doc` result for the transcript, without theme colours.
86
+ * Describe a file-read result for the transcript, without theme colours.
85
87
  *
86
88
  * A complete first read shows the total; a paged read shows the returned range
87
89
  * and where to continue, so a truncated result is never mistaken for the whole
88
- * document.
90
+ * file.
89
91
  */
90
- export function summarizeDoc(input: DocSummaryInput): DocSummary {
92
+ export function summarizeRead(input: SummaryInput): PageSummary {
91
93
  const format = input.format.toUpperCase();
92
94
  if (input.chars === 0) return { format, detail: "no extractable text", truncated: false };
93
95
  if (input.startIndex >= input.chars) {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Transcript rendering for `read_doc`.
2
+ * Transcript rendering for the `read_doc` tool.
3
3
  *
4
- * Theme-aware formatting only: the tool definition in `tool.ts` wires these to
4
+ * Theme-aware formatting only: the tool definitions in `tool.ts` wire these to
5
5
  * `renderCall` / `renderResult`. The call line carries the path (body colour,
6
6
  * `~`-shortened, OSC-8 linked when the terminal supports it); the result line
7
7
  * leads with the format and the exact character range, so the path is never
@@ -17,14 +17,14 @@ import { formatTokens, sanitizeLine, shortenPath, stripControlChars } from "../.
17
17
  import { expandKey, SEPARATORS } from "../../lib/tui/glyphs.ts";
18
18
  import { paint } from "../../lib/tui/tokens.ts";
19
19
  import { rejectionLine, toolCallTitle } from "../../lib/tui/tool.ts";
20
- import { summarizeDoc } from "./paging.ts";
21
- import { TOOL_NAME, type DocArgs, type DocResult } from "./schema.ts";
20
+ import { summarizeRead } from "./paging.ts";
21
+ import type { ReadArgs, ReadResult } from "./schema.ts";
22
22
 
23
23
  /** Lines of extracted text shown in the expanded preview. */
24
24
  export const PREVIEW_LINES = 20;
25
25
 
26
26
  /** Render the argument path: body colour, `~`-shortened, OSC-8 linked when supported. */
27
- export function renderDocPath(rawPath: string | undefined, theme: Theme, cwd: string, argsComplete = true): string {
27
+ export function renderReadPath(rawPath: string | undefined, theme: Theme, cwd: string, argsComplete = true): string {
28
28
  if (rawPath === undefined && !argsComplete) return theme.fg("toolOutput", "...");
29
29
  if (typeof rawPath !== "string") return paint(theme, "bad", "[invalid arg]");
30
30
  const value = sanitizeLine(rawPath);
@@ -47,14 +47,14 @@ export function previewText(text: string, maxLines: number): { body: string; mor
47
47
  }
48
48
 
49
49
  /** A result shape broad enough for both a test double and the host. */
50
- export interface DocRenderResult {
50
+ export interface ReadRenderResult {
51
51
  content: ReadonlyArray<{ type: string; text?: string }>;
52
52
  details?: unknown;
53
53
  isError?: boolean;
54
54
  }
55
55
 
56
56
  /** Options the host passes to `renderResult`. */
57
- export interface DocRenderOptions {
57
+ export interface ReadRenderOptions {
58
58
  expanded: boolean;
59
59
  isPartial: boolean;
60
60
  /** Pre-painted elapsed suffix shown while reading. */
@@ -62,10 +62,14 @@ export interface DocRenderOptions {
62
62
  }
63
63
 
64
64
  /** The call line: `read_doc <path> (from N, max M)`. */
65
- export function formatDocCall(args: DocArgs, theme: Theme, context: { cwd: string; argsComplete?: boolean }): string {
65
+ export function formatReadCall(
66
+ tool: string,
67
+ args: ReadArgs,
68
+ theme: Theme,
69
+ context: { cwd: string; argsComplete?: boolean },
70
+ ): string {
66
71
  const { path, startIndex, maxChars } = args;
67
- let text =
68
- toolCallTitle(theme, `${TOOL_NAME} `) + renderDocPath(path, theme, context.cwd, context.argsComplete ?? true);
72
+ let text = toolCallTitle(theme, `${tool} `) + renderReadPath(path, theme, context.cwd, context.argsComplete ?? true);
69
73
  const options: string[] = [];
70
74
  if (typeof startIndex === "number" && startIndex > 0) options.push(`from ${formatTokens(startIndex)}`);
71
75
  if (typeof maxChars === "number") options.push(`max ${formatTokens(maxChars)}`);
@@ -77,17 +81,17 @@ export function formatDocCall(args: DocArgs, theme: Theme, context: { cwd: strin
77
81
  * The result: a format + range summary, an expand hint when collapsed, and, when
78
82
  * expanded, a sanitized preview of the extracted text.
79
83
  */
80
- export function formatDocResult(result: DocRenderResult, options: DocRenderOptions, theme: Theme): string {
84
+ export function formatReadResult(result: ReadRenderResult, options: ReadRenderOptions, theme: Theme): string {
81
85
  if (options.isPartial) return paint(theme, "warn", "Reading...") + (options.progress ?? "");
82
86
 
83
87
  const isError = result.isError ?? false;
84
- const details = result.details as DocResult | undefined;
88
+ const details = result.details as ReadResult | undefined;
85
89
 
86
90
  if (!details || isError) {
87
91
  return rejectionLine(theme, { content: result.content, isError }, options.expanded);
88
92
  }
89
93
 
90
- const summary = summarizeDoc(details);
94
+ const summary = summarizeRead(details);
91
95
  const sep = paint(theme, "faint", SEPARATORS.item);
92
96
  let text = paint(theme, "meta", summary.format) + sep + theme.fg("toolOutput", summary.detail);
93
97
  if (summary.note) text += paint(theme, summary.truncated ? "accent" : "faint", SEPARATORS.item + summary.note);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Parameter schema, output schema, and result type for `read_doc`.
2
+ * Parameter schema, output schema, and result types for the `read_doc` tool.
3
3
  */
4
4
 
5
5
  import { Type, type Static } from "typebox";
@@ -12,16 +12,16 @@ export const MIN_CHARS = 200;
12
12
  export const MAX_CHARS = 100_000;
13
13
  export const MAX_PATH_LENGTH = 4096;
14
14
 
15
- export const DocParams = Type.Object({
15
+ export const ReadParams = Type.Object({
16
16
  path: Type.String({
17
17
  minLength: 1,
18
18
  maxLength: MAX_PATH_LENGTH,
19
- description: "Path to a supported document file, relative to the working directory or absolute inside it.",
19
+ description: "Path to a supported file, relative to the working directory or absolute inside it.",
20
20
  }),
21
21
  startIndex: Type.Optional(
22
22
  Type.Integer({
23
23
  minimum: 0,
24
- description: "Code-point offset to start from. Use this to read a truncated document in chunks.",
24
+ description: "Code-point offset to start from. Use this to read a truncated file in chunks.",
25
25
  }),
26
26
  ),
27
27
  maxChars: Type.Optional(
@@ -33,9 +33,9 @@ export const DocParams = Type.Object({
33
33
  ),
34
34
  });
35
35
 
36
- export type DocArgs = Static<typeof DocParams>;
36
+ export type ReadArgs = Static<typeof ReadParams>;
37
37
 
38
- export const DocOutput = Type.Object({
38
+ export const ReadOutput = Type.Object({
39
39
  path: Type.String(),
40
40
  format: Type.String(),
41
41
  bytes: Type.Number(),
@@ -47,4 +47,4 @@ export const DocOutput = Type.Object({
47
47
  });
48
48
 
49
49
  /** The runtime result, with `format` narrowed to the registry id. */
50
- export type DocResult = Omit<Static<typeof DocOutput>, "format"> & { format: FormatId };
50
+ export type ReadResult = Omit<Static<typeof ReadOutput>, "format"> & { format: FormatId };
@@ -19,16 +19,14 @@ import { reuseText } from "../../lib/tui/chrome.ts";
19
19
  import { elapsedSuffix, reuseCallText, startToolProgress, syncElapsedTimer } from "../../lib/tui/tool.ts";
20
20
  import { isFormatEnabled, loadConfig } from "./config.ts";
21
21
  import { withDeadline } from "./deadline.ts";
22
- import type { DocExtractor } from "./extract/file.ts";
22
+ import type { FormatExtractor } from "./extract/file.ts";
23
23
  import { detectFormat, supportedExtensions, unsupportedHint, type FormatId } from "./formats.ts";
24
- import { formatDoc } from "./paging.ts";
25
- import { formatDocCall, formatDocResult } from "./render.ts";
26
- import { DocOutput, DocParams, MAX_CHARS, MIN_CHARS, TOOL_NAME, type DocArgs, type DocResult } from "./schema.ts";
27
-
28
- export { TOOL_NAME } from "./schema.ts";
24
+ import { formatPage } from "./paging.ts";
25
+ import { formatReadCall, formatReadResult } from "./render.ts";
26
+ import { ReadOutput, ReadParams, MAX_CHARS, MIN_CHARS, TOOL_NAME, type ReadArgs, type ReadResult } from "./schema.ts";
29
27
 
30
28
  /** Resolve a request path under `root`, refusing anything that escapes it. */
31
- function resolveDocument(root: string, rawPath: string): { display: string; abs: string } {
29
+ function resolveReadPath(root: string, rawPath: string): { display: string; abs: string } {
32
30
  // Resolve on the raw path; sanitize only the displayed/error strings, so a
33
31
  // pathological control character cannot hide a path or forge a transcript line.
34
32
  const trimmed = rawPath.trim();
@@ -44,7 +42,7 @@ function resolveDocument(root: string, rawPath: string): { display: string; abs:
44
42
  }
45
43
 
46
44
  /** Stat the file and raise a model-readable error for a missing/unreadable target. */
47
- async function statFile(abs: string, display: string, maxFileBytes: number): Promise<number> {
45
+ async function statFile(abs: string, display: string, maxFileBytes: number, tool: string): Promise<number> {
48
46
  let info: Awaited<ReturnType<typeof stat>>;
49
47
  try {
50
48
  info = await stat(abs);
@@ -56,54 +54,57 @@ async function statFile(abs: string, display: string, maxFileBytes: number): Pro
56
54
  }
57
55
  if (!info.isFile()) throw new Error(`Not a file: ${display}`);
58
56
  if (info.size > maxFileBytes) {
59
- throw new Error(`File is ${info.size} bytes, larger than the ${maxFileBytes}-byte limit for read_doc.`);
57
+ throw new Error(`File is ${info.size} bytes, larger than the ${maxFileBytes}-byte limit for ${tool}.`);
60
58
  }
61
59
  return info.size;
62
60
  }
63
61
 
64
62
  /** Per-format extractor overrides, for tests; the format table supplies the rest. */
65
- export interface DocToolDeps {
66
- extractors?: Partial<Record<FormatId, DocExtractor>>;
67
- /** Test seam: replace the configured `document.extractTimeoutMs`. */
63
+ export interface ReadToolDeps {
64
+ extractors?: Partial<Record<FormatId, FormatExtractor>>;
65
+ /** Test seam: replace the configured `file-read.extractTimeoutMs`. */
68
66
  timeoutMs?: number;
69
67
  }
70
68
 
71
- export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void {
69
+ export function registerReadTool(pi: ExtensionAPI, deps: ReadToolDeps = {}): void {
72
70
  pi.registerTool({
73
71
  name: TOOL_NAME,
74
72
  label: "Read document",
75
73
  description:
76
74
  "Read a local PDF, DOCX, DOC, ODT, RTF, or XLSX file and return its extracted text, pageable with " +
77
75
  "startIndex/maxChars. Extraction runs in process, so no external tools are needed; a read that outlasts " +
78
- "document.extractTimeoutMs fails with the limit named.",
76
+ "file-read.extractTimeoutMs fails with the limit named.",
79
77
  promptSnippet: "Read a PDF, DOCX, DOC, ODT, RTF, or XLSX as text, paged with startIndex/maxChars.",
80
78
  promptGuidelines: [
81
- "Use read_doc for .pdf/.docx/.doc/.odt/.rtf/.xlsx; the built-in read tool is for text and images.",
82
- "Extraction returns plain text only; tables, images, and formatting are dropped.",
79
+ "Use read_doc for .pdf, .docx, .doc, .odt, .rtf, and .xlsx; the built-in read tool is for text and images.",
80
+ "Only text survives: tables, images, and formatting are dropped, and an .xlsx read returns one [Sheet] heading per worksheet followed by tab-separated rows of the workbook's displayed values.",
81
+ "A scanned or image-only PDF has no text layer, so it returns little or nothing; there is no OCR.",
83
82
  ],
84
- parameters: DocParams,
85
- outputSchema: DocOutput,
83
+ parameters: ReadParams,
84
+ outputSchema: ReadOutput,
86
85
  annotations: { readOnlyHint: true, openWorldHint: false },
87
86
 
88
87
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
89
- const args = params as DocArgs;
88
+ const args = params as ReadArgs;
90
89
  const config = loadConfig(ctx.cwd);
91
90
  const root = resolveEffectiveCwd(ctx.cwd);
92
- const { display, abs } = resolveDocument(root, args.path);
91
+ const { display, abs } = resolveReadPath(root, args.path);
93
92
 
94
93
  const format = detectFormat(display);
95
94
  if (!format) {
96
95
  const hint = unsupportedHint(display);
97
96
  if (hint) throw new Error(hint);
98
- throw new Error(`Unsupported document format. Supported extensions: ${supportedExtensions().join(", ")}.`);
97
+ throw new Error(
98
+ `Unsupported file format for ${TOOL_NAME}. Supported extensions: ${supportedExtensions().join(", ")}.`,
99
+ );
99
100
  }
100
101
  if (!isFormatEnabled(config, format.id)) {
101
102
  throw new Error(
102
- `${format.label} extraction is disabled in the bytefly.json document section (document.formats.${format.id} = false).`,
103
+ `${format.label} extraction is disabled in the bytefly.json file-read section (file-read.formats.${format.id} = false).`,
103
104
  );
104
105
  }
105
106
 
106
- const size = await statFile(abs, display, config.maxFileBytes);
107
+ const size = await statFile(abs, display, config.maxFileBytes, TOOL_NAME);
107
108
  const extractor = deps.extractors?.[format.id] ?? format.extract;
108
109
  const timeoutMs = deps.timeoutMs ?? config.extractTimeoutMs;
109
110
  startToolProgress(onUpdate);
@@ -111,7 +112,7 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
111
112
  timeoutMs,
112
113
  signal,
113
114
  timeoutMessage:
114
- `Extraction of ${display} did not finish within ${timeoutMs} ms (document.extractTimeoutMs). ` +
115
+ `Extraction of ${display} did not finish within ${timeoutMs} ms (file-read.extractTimeoutMs). ` +
115
116
  `The ${format.label} parser was abandoned; raise the limit or read a smaller file.`,
116
117
  });
117
118
 
@@ -119,7 +120,8 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
119
120
  const requested = typeof args.maxChars === "number" ? args.maxChars : config.maxChars;
120
121
  const maxChars = Math.min(config.maxChars, MAX_CHARS, Math.max(MIN_CHARS, Math.round(requested)));
121
122
 
122
- const formatted = formatDoc({
123
+ const formatted = formatPage({
124
+ tool: TOOL_NAME,
123
125
  path: display,
124
126
  format: format.id,
125
127
  bytes: size,
@@ -128,7 +130,7 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
128
130
  maxChars,
129
131
  });
130
132
 
131
- const result: DocResult = {
133
+ const result: ReadResult = {
132
134
  path: display,
133
135
  format: format.id,
134
136
  bytes: size,
@@ -149,7 +151,9 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
149
151
  renderCall(args, theme, context) {
150
152
  const cwd = context?.cwd ?? process.cwd();
151
153
  const text = reuseCallText(context?.lastComponent, "", "middle");
152
- text.setText(formatDocCall(args as DocArgs, theme, { cwd, argsComplete: context?.argsComplete ?? true }));
154
+ text.setText(
155
+ formatReadCall(TOOL_NAME, args as ReadArgs, theme, { cwd, argsComplete: context?.argsComplete ?? true }),
156
+ );
153
157
  return text;
154
158
  },
155
159
 
@@ -159,7 +163,11 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
159
163
  const isError = context?.isError ?? result.isError ?? false;
160
164
  const text = reuseText(context?.lastComponent);
161
165
  text.setText(
162
- formatDocResult({ content: result.content, details: result.details, isError }, { ...options, progress }, theme),
166
+ formatReadResult(
167
+ { content: result.content, details: result.details, isError },
168
+ { ...options, progress },
169
+ theme,
170
+ ),
163
171
  );
164
172
  return text;
165
173
  },
@@ -9,10 +9,9 @@
9
9
 
10
10
  import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { sanitizeLine } from "../../lib/format.ts";
13
12
  import { GOAL_TOOL } from "../../lib/tool-names.ts";
13
+ import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
14
14
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
16
15
  import { paint } from "../../lib/tui/tokens.ts";
17
16
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
17
  import { formatCallText, formatGoalText } from "./format.ts";
@@ -118,25 +117,22 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
118
117
  },
119
118
 
120
119
  renderCall(args, theme, context) {
121
- const text = reuseCallText(context.lastComponent);
122
- text.setText(
123
- toolCallTitle(theme, `${TOOL_NAME} `) +
124
- paint(theme, "meta", formatCallText(args.objective, context.argsComplete, args.status)),
120
+ return callLine(
121
+ theme,
122
+ context.lastComponent,
123
+ TOOL_NAME,
124
+ formatCallText(args.objective, context.argsComplete, args.status),
125
125
  );
126
- return text;
127
126
  },
128
127
 
129
128
  renderResult(result, options, theme, context) {
130
129
  const text = reuseText(context.lastComponent);
131
- const details = toolDetails<GoalDetails>(result.details);
132
- if (!details) {
133
- text.setText(rejectionLine(theme, result, options.expanded));
134
- return text;
135
- }
136
- if (details.error) {
137
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
130
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<GoalDetails>(result.details));
131
+ if ("line" in outcome) {
132
+ text.setText(outcome.line);
138
133
  return text;
139
134
  }
135
+ const details = outcome.details;
140
136
  if (!details.goal) {
141
137
  // A neutral marker, deliberately not the achieved check.
142
138
  text.setText(paint(theme, "faint", `${GLYPHS.stopped} `) + paint(theme, "meta", "Cleared the goal"));
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { withRailsSuppressed } from "../../lib/rails.ts";
9
+ import { withPolledScreen } from "../../lib/screen-session.ts";
10
10
  import { formatJobList } from "./format.ts";
11
11
  import type { JobsRuntime } from "./runtime.ts";
12
12
  import { JobListComponent } from "./tui.ts";
@@ -23,53 +23,39 @@ export function registerCommands(pi: ExtensionAPI, runtime: JobsRuntime): void {
23
23
  return;
24
24
  }
25
25
 
26
- let timer: ReturnType<typeof setInterval> | undefined;
27
- const stopTimer = (): void => {
28
- if (timer) clearInterval(timer);
29
- timer = undefined;
30
- };
31
-
32
- // The dock screen owns the editor slot; hide the rails for its lifetime.
33
- try {
34
- await withRailsSuppressed(pi, () =>
35
- ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
36
- const component = new JobListComponent(
37
- () => runtime.list(),
38
- theme,
39
- {
40
- sessionId: () => runtime.sessionId,
41
- logs: (id) => runtime.logs(id, 200),
42
- kill: (id) => {
43
- runtime.kill(id);
44
- },
45
- clear: () => {
46
- const result = runtime.clear(undefined, false);
47
- if (result.cleared > 0) {
48
- ctx.ui.notify(`Cleared ${result.cleared} job${result.cleared === 1 ? "" : "s"}.`, "info");
49
- }
50
- },
26
+ await withPolledScreen(
27
+ pi,
28
+ ctx,
29
+ (screen) =>
30
+ new JobListComponent(
31
+ () => runtime.list(),
32
+ screen.theme,
33
+ {
34
+ sessionId: () => runtime.sessionId,
35
+ logs: (id) => runtime.logs(id, 200),
36
+ kill: (id) => {
37
+ runtime.kill(id);
51
38
  },
52
- () => {
53
- stopTimer();
54
- done();
39
+ clear: () => {
40
+ const result = runtime.clear(undefined, false);
41
+ if (result.cleared > 0) {
42
+ ctx.ui.notify(`Cleared ${result.cleared} job${result.cleared === 1 ? "" : "s"}.`, "info");
43
+ }
55
44
  },
56
- () => tui.requestRender(),
57
- () => tui.terminal?.rows,
58
- );
59
-
60
- // Poll the open log pane; the runtime's own clock repaints the list.
61
- timer = setInterval(() => {
62
- const id = component.currentLogId();
63
- if (id) component.refreshLogs(id);
64
- }, LOG_POLL_MS);
65
- timer.unref?.();
66
-
67
- return component;
68
- }),
69
- );
70
- } finally {
71
- stopTimer();
72
- }
45
+ },
46
+ screen.close,
47
+ screen.requestRender,
48
+ screen.viewportRows,
49
+ ),
50
+ {
51
+ pollMs: LOG_POLL_MS,
52
+ // Only the open log pane is polled; the runtime's own clock repaints the list.
53
+ poll: (component) => {
54
+ const id = component.currentLogId();
55
+ if (id) component.refreshLogs(id);
56
+ },
57
+ },
58
+ );
73
59
  },
74
60
  });
75
61
  }
@@ -34,6 +34,7 @@ import {
34
34
  import { loadConfig, type JobsConfig } from "./config.ts";
35
35
  import { createLogWriter, type LogHandle } from "./log-writer.ts";
36
36
  import { sanitizeUntrustedLine } from "../../lib/format.ts";
37
+ import { defaultInterval } from "../../lib/timer.ts";
37
38
  import { clipLabel } from "../../lib/tui/columns.ts";
38
39
  import { compareJobs, formatLogs } from "./format.ts";
39
40
  import { readLogTail } from "./logs.ts";
@@ -193,7 +194,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
193
194
  let config: JobsConfig = options.config ?? loadConfig(process.cwd());
194
195
  let sessionId = "session";
195
196
  let disposed = false;
196
- let clock: ReturnType<typeof setInterval> | undefined;
197
+ let clock: (() => void) | undefined;
197
198
 
198
199
  /** Jobs this session owns (a dead session's adopted records included). */
199
200
  const ownJobs = (): Job[] => [...jobs.values()].filter((job) => job.sessionId === sessionId);
@@ -247,10 +248,8 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
247
248
  isSessionAlive(readSessionMarker(store.directory(), owner), now(), config.sessionTtlMs, liveness);
248
249
 
249
250
  const stopClock = (): void => {
250
- if (clock) {
251
- clearInterval(clock);
252
- clock = undefined;
253
- }
251
+ clock?.();
252
+ clock = undefined;
254
253
  };
255
254
 
256
255
  const ensureClock = (): void => {
@@ -260,7 +259,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
260
259
  return;
261
260
  }
262
261
  if (clock) return;
263
- clock = setInterval(() => {
262
+ clock = defaultInterval(config.repaintMs, () => {
264
263
  touchMarker();
265
264
  pollExternal();
266
265
  // Re-assert the chip every tick. It is otherwise only set on a job
@@ -268,8 +267,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
268
267
  // another UI path) would hide a still-running job's chip until the next
269
268
  // transition. The clock is the only periodic hook while jobs run.
270
269
  ui.setStatus();
271
- }, config.repaintMs);
272
- clock.unref?.();
270
+ });
273
271
  };
274
272
 
275
273
  const paint = (): void => {