@gavin-hu/my-pi-agent 0.12.0 → 0.13.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/README.md +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +139 -67
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +36 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/register.ts +13 -2
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +4 -3
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +98 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +60 -29
- package/extensions/plan/commands.ts +67 -8
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- package/extensions/web-access/README.md +43 -13
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +10 -8
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +282 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published-version answer, cached on disk with the time it was read.
|
|
3
|
+
*
|
|
4
|
+
* The check already fires once per session, which leaves two gaps: a session open
|
|
5
|
+
* for days never re-reads npm, and every new session pays for a request it made
|
|
6
|
+
* minutes earlier. A timestamp is what fixes the second, so the cache lives in the
|
|
7
|
+
* agent directory rather than in memory — one entry, one window, shared by every
|
|
8
|
+
* session that starts inside it.
|
|
9
|
+
*
|
|
10
|
+
* A cache can only ever be a miss. An absent, unreadable, or wrong-typed file
|
|
11
|
+
* reads as `undefined` and the caller fetches; nothing here throws, because a
|
|
12
|
+
* stale file must never become a session error.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { writeConfigFile } from "../../../lib/config.ts";
|
|
18
|
+
import { parseVersion } from "./version.ts";
|
|
19
|
+
|
|
20
|
+
/** The file name under `<agentDir>/bytefly/`. */
|
|
21
|
+
export const UPDATE_CACHE_FILE = "update.json";
|
|
22
|
+
|
|
23
|
+
/** How long a cached answer is reused before the next session re-reads npm. */
|
|
24
|
+
export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** One cached answer: the newest published version and when it was read. */
|
|
27
|
+
export interface UpdateCacheEntry {
|
|
28
|
+
/** The version npm's `latest` tag pointed at. */
|
|
29
|
+
latest: string;
|
|
30
|
+
/** Epoch milliseconds of the read that produced `latest`. */
|
|
31
|
+
checkedAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where the cache lives for an agent directory. */
|
|
35
|
+
export function updateCachePath(agentDir: string): string {
|
|
36
|
+
return join(agentDir, "bytefly", UPDATE_CACHE_FILE);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the cache; a missing, corrupt, or mistyped entry is `undefined`. */
|
|
40
|
+
export function readUpdateCache(path: string): UpdateCacheEntry | undefined {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown> | undefined;
|
|
43
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
44
|
+
const latest = typeof raw.latest === "string" ? raw.latest.trim() : "";
|
|
45
|
+
const checkedAt = typeof raw.checkedAt === "number" && Number.isFinite(raw.checkedAt) ? raw.checkedAt : 0;
|
|
46
|
+
if (!parseVersion(latest) || checkedAt <= 0) return undefined;
|
|
47
|
+
return { latest, checkedAt };
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write the cache atomically (a sibling temp file, then a rename). The caller
|
|
55
|
+
* treats a throw as a miss: a read-only agent directory must not break a session.
|
|
56
|
+
*/
|
|
57
|
+
export function writeUpdateCache(path: string, entry: UpdateCacheEntry): void {
|
|
58
|
+
writeConfigFile(path, { latest: entry.latest, checkedAt: entry.checkedAt });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether an entry is still inside the window. The boundary is inclusive: an
|
|
63
|
+
* entry read exactly `ttlMs` ago is reused, so the window never flickers.
|
|
64
|
+
*/
|
|
65
|
+
export function isFresh(
|
|
66
|
+
entry: UpdateCacheEntry | undefined,
|
|
67
|
+
now: number,
|
|
68
|
+
ttlMs: number = UPDATE_CACHE_TTL_MS,
|
|
69
|
+
): boolean {
|
|
70
|
+
if (!entry) return false;
|
|
71
|
+
const age = now - entry.checkedAt;
|
|
72
|
+
return age >= 0 && age <= ttlMs;
|
|
73
|
+
}
|
|
@@ -1,32 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Wording for the update check: the header marker
|
|
2
|
+
* Wording for the update check: the header marker, the one transcript notice, and
|
|
3
|
+
* the answer an explicit re-check prints.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* the registry publishes — and
|
|
6
|
-
* honest form (`↑0.12.0`)
|
|
7
|
-
*
|
|
5
|
+
* All three read the same two facts — the version this session loaded and the
|
|
6
|
+
* version the registry publishes — and none adds a third. The marker is the
|
|
7
|
+
* shortest honest form (`↑0.12.0`); the notice names the command and the release
|
|
8
|
+
* URL as text, because the notice renderer has no link support; and the re-check
|
|
9
|
+
* says what it just found, including the two cases it cannot answer (offline, and
|
|
10
|
+
* a registry it could not read).
|
|
8
11
|
*/
|
|
9
12
|
|
|
13
|
+
import pkg from "../../../package.json";
|
|
10
14
|
import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
|
|
11
15
|
import { GLYPHS } from "../../../lib/tui/glyphs.ts";
|
|
16
|
+
import type { UpdateOutcome } from "./state.ts";
|
|
12
17
|
|
|
13
18
|
/** The header marker: an up arrow and the newest version, e.g. `↑0.12.0`. */
|
|
14
19
|
export function updateMarker(latest: string): string {
|
|
15
20
|
return `${GLYPHS.update}${latest}`;
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The release page for a version, or `undefined` when this checkout does not name
|
|
25
|
+
* a GitHub repository. Derived from `package.json` so a rename cannot leave a dead
|
|
26
|
+
* link behind in the notice.
|
|
27
|
+
*/
|
|
28
|
+
export function releaseUrl(version: string): string | undefined {
|
|
29
|
+
const url = (pkg.repository as { url?: string } | undefined)?.url ?? "";
|
|
30
|
+
const base = url.replace(/\.git$/, "");
|
|
31
|
+
if (!/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(base)) return undefined;
|
|
32
|
+
return `${base}/releases/tag/v${version}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
/** The single notice a session posts when a newer version is published. */
|
|
19
36
|
export function updateNotice(current: string, latest: string): NoticeMessage {
|
|
37
|
+
const release = releaseUrl(latest);
|
|
38
|
+
const command = "pi update --extensions";
|
|
20
39
|
return noticeMessage({
|
|
21
|
-
content:
|
|
40
|
+
content:
|
|
41
|
+
`bytefly ${latest} is available (current ${current}). Run \`${command}\` to update.` +
|
|
42
|
+
(release ? ` Release notes: ${release}` : ""),
|
|
22
43
|
details: {
|
|
23
44
|
source: "bytefly",
|
|
24
45
|
title: `${latest} is available`,
|
|
25
46
|
severity: "info",
|
|
26
|
-
items:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
47
|
+
items: release
|
|
48
|
+
? [
|
|
49
|
+
{ label: "current", detail: `v${current}` },
|
|
50
|
+
{ label: "update", detail: command },
|
|
51
|
+
{ label: "release", detail: release },
|
|
52
|
+
]
|
|
53
|
+
: [
|
|
54
|
+
{ label: "current", detail: `v${current}` },
|
|
55
|
+
{ label: "update", detail: command },
|
|
56
|
+
],
|
|
30
57
|
},
|
|
31
58
|
});
|
|
32
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What `/bytefly update` prints. The background check is silent because nobody
|
|
63
|
+
* asked; here someone did, so a skip and a failed read are answers too.
|
|
64
|
+
*/
|
|
65
|
+
export function updateOutcomeText(
|
|
66
|
+
outcome: UpdateOutcome,
|
|
67
|
+
current: string,
|
|
68
|
+
): { text: string; severity: "info" | "warning" } {
|
|
69
|
+
switch (outcome.kind) {
|
|
70
|
+
case "offline":
|
|
71
|
+
return { text: "bytefly: the update check was skipped (PI_OFFLINE is set).", severity: "info" };
|
|
72
|
+
case "failed":
|
|
73
|
+
return {
|
|
74
|
+
text: "bytefly: could not read the published version — check the network and try again.",
|
|
75
|
+
severity: "warning",
|
|
76
|
+
};
|
|
77
|
+
case "current":
|
|
78
|
+
return { text: `bytefly: up to date — v${outcome.latest} is the newest published version.`, severity: "info" };
|
|
79
|
+
case "available": {
|
|
80
|
+
const release = releaseUrl(outcome.latest);
|
|
81
|
+
return {
|
|
82
|
+
text: `bytefly: v${outcome.latest} is available (current ${current}).${release ? ` ${release}` : ""}`,
|
|
83
|
+
severity: "info",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The update check's lifecycle:
|
|
3
|
-
* session, record
|
|
2
|
+
* The update check's lifecycle: reuse the cached answer or read the registry once
|
|
3
|
+
* per interactive session, record a newer version, and announce it once per load.
|
|
4
4
|
*
|
|
5
5
|
* The request is fired without awaiting. Pi awaits `session_start` handlers, so
|
|
6
6
|
* a slow registry would otherwise hold the first prompt; Pi's own version check
|
|
@@ -8,49 +8,106 @@
|
|
|
8
8
|
* version is silent, and a generation counter keeps a request that resolves
|
|
9
9
|
* after shutdown from writing into the next session's state.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* A cached answer with a timestamp is what makes a long-lived session different
|
|
12
|
+
* from a fresh one: a session that starts inside the window reuses the last read
|
|
13
|
+
* instead of repeating it, and `recheck()` ignores the window so `/bytefly update`
|
|
14
|
+
* can look again without a restart. `/bytefly` and `doctor` do not report the
|
|
15
|
+
* version: an available update is not a prerequisite problem.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
20
|
import pkg from "../../../package.json";
|
|
17
21
|
import { isOffline } from "../../../lib/env.ts";
|
|
22
|
+
import { isFresh, readUpdateCache, updateCachePath, writeUpdateCache, type UpdateCacheEntry } from "./cache.ts";
|
|
18
23
|
import { fetchLatestVersion } from "./check.ts";
|
|
19
24
|
import { updateNotice } from "./format.ts";
|
|
20
|
-
import type { UpdateSource } from "./state.ts";
|
|
25
|
+
import type { UpdateOutcome, UpdateSource } from "./state.ts";
|
|
21
26
|
import { isNewerVersion } from "./version.ts";
|
|
22
27
|
|
|
23
|
-
/**
|
|
28
|
+
/** The cache, as the lifecycle sees it: read on start, written after a read. */
|
|
29
|
+
export interface UpdateCache {
|
|
30
|
+
/** The last recorded answer, or `undefined` for a miss. */
|
|
31
|
+
read(): UpdateCacheEntry | undefined;
|
|
32
|
+
/** Record an answer; a throw is treated as a miss. */
|
|
33
|
+
write(entry: UpdateCacheEntry): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What `registerUpdate` returns, so the panel can re-run the check on demand. */
|
|
37
|
+
export interface UpdateHandle {
|
|
38
|
+
/** Read the registry now, ignoring the cache window. Never throws. */
|
|
39
|
+
recheck(): Promise<UpdateOutcome>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Test seams; production uses the registry read, `PI_OFFLINE`, and the agent dir. */
|
|
24
43
|
export interface UpdateDeps {
|
|
25
44
|
/** Replace the registry read; a rejection counts as no answer. */
|
|
26
45
|
fetchLatest?: () => Promise<string | undefined>;
|
|
27
46
|
/** Replace the `PI_OFFLINE` check. */
|
|
28
47
|
isOffline?: () => boolean;
|
|
48
|
+
/** Replace the disk cache. */
|
|
49
|
+
cache?: UpdateCache;
|
|
50
|
+
/** Replace the clock the cache timestamp is read from. */
|
|
51
|
+
now?: () => number;
|
|
29
52
|
/** Called once a newer version is recorded, so a mounted surface repaints. */
|
|
30
53
|
onUpdate?: () => void;
|
|
31
54
|
}
|
|
32
55
|
|
|
56
|
+
/** The on-disk cache under the agent directory, built lazily so it is per call. */
|
|
57
|
+
function diskCache(): UpdateCache {
|
|
58
|
+
return {
|
|
59
|
+
read: () => readUpdateCache(updateCachePath(getAgentDir())),
|
|
60
|
+
write: (entry) => {
|
|
61
|
+
try {
|
|
62
|
+
writeUpdateCache(updateCachePath(getAgentDir()), entry);
|
|
63
|
+
} catch {
|
|
64
|
+
// A read-only agent directory costs the next session one request, nothing more.
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
/**
|
|
34
71
|
* Register the check. `updates` is the same store the header reads, so recording
|
|
35
72
|
* a version and calling `onUpdate` is what puts the marker on screen.
|
|
36
73
|
*/
|
|
37
|
-
export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}):
|
|
74
|
+
export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}): UpdateHandle {
|
|
38
75
|
const readLatest = deps.fetchLatest ?? (() => fetchLatestVersion());
|
|
39
76
|
const offline = deps.isOffline ?? isOffline;
|
|
77
|
+
const cache = deps.cache ?? diskCache();
|
|
78
|
+
const now = deps.now ?? Date.now;
|
|
40
79
|
// A session is announced at most once per load, even if `session_start` fires
|
|
41
80
|
// again after a resume or a session replacement.
|
|
42
81
|
let announced = false;
|
|
43
82
|
let generation = 0;
|
|
44
83
|
|
|
84
|
+
/** Record a newer version and repaint the marker. */
|
|
85
|
+
const paint = (latest: string): void => {
|
|
86
|
+
if (!isNewerVersion(latest, pkg.version)) return;
|
|
87
|
+
updates.set(latest);
|
|
88
|
+
deps.onUpdate?.();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The once-per-load transcript notice, posted only for a strictly newer version. */
|
|
92
|
+
const announce = (latest: string): void => {
|
|
93
|
+
if (announced || !isNewerVersion(latest, pkg.version)) return;
|
|
94
|
+
announced = true;
|
|
95
|
+
pi.sendMessage(updateNotice(pkg.version, latest));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** One answer, from the cache or the registry: paint the marker, announce it once. */
|
|
99
|
+
const record = (entry: UpdateCacheEntry): void => {
|
|
100
|
+
paint(entry.latest);
|
|
101
|
+
announce(entry.latest);
|
|
102
|
+
};
|
|
103
|
+
|
|
45
104
|
const check = async (token: number): Promise<void> => {
|
|
46
105
|
try {
|
|
47
106
|
const latest = await readLatest();
|
|
48
107
|
if (latest === undefined || token !== generation) return;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
deps.onUpdate?.();
|
|
53
|
-
pi.sendMessage(updateNotice(pkg.version, latest));
|
|
108
|
+
const entry = { latest, checkedAt: now() };
|
|
109
|
+
cache.write(entry);
|
|
110
|
+
record(entry);
|
|
54
111
|
} catch {
|
|
55
112
|
// A background check never surfaces an error: the session does not know it ran.
|
|
56
113
|
}
|
|
@@ -60,7 +117,19 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
|
|
|
60
117
|
// The marker lives in bytefly's header and the notice in a transcript, so a
|
|
61
118
|
// non-interactive run has nothing to draw and makes no request.
|
|
62
119
|
if (ctx.mode !== "tui") return;
|
|
63
|
-
if (
|
|
120
|
+
if (offline()) {
|
|
121
|
+
// No request, so the window does not matter: the cached answer is the best
|
|
122
|
+
// one available, and the marker is advisory.
|
|
123
|
+
const cached = cache.read();
|
|
124
|
+
if (cached) record(cached);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const cached = cache.read();
|
|
128
|
+
if (cached && isFresh(cached, now())) {
|
|
129
|
+
record(cached);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (announced) return;
|
|
64
133
|
void check(generation);
|
|
65
134
|
});
|
|
66
135
|
|
|
@@ -69,4 +138,21 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
|
|
|
69
138
|
generation++;
|
|
70
139
|
updates.set();
|
|
71
140
|
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
async recheck() {
|
|
144
|
+
if (offline()) return { kind: "offline" };
|
|
145
|
+
let latest: string | undefined;
|
|
146
|
+
try {
|
|
147
|
+
latest = await readLatest();
|
|
148
|
+
} catch {
|
|
149
|
+
return { kind: "failed" };
|
|
150
|
+
}
|
|
151
|
+
if (latest === undefined) return { kind: "failed" };
|
|
152
|
+
cache.write({ latest, checkedAt: now() });
|
|
153
|
+
// The caller prints the answer, so this path never posts a notice.
|
|
154
|
+
paint(latest);
|
|
155
|
+
return isNewerVersion(latest, pkg.version) ? { kind: "available", latest } : { kind: "current", latest };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
72
158
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where a surface reads the newest published version this load found
|
|
2
|
+
* Where a surface reads the newest published version this load found, and what an
|
|
3
|
+
* explicit re-check reported.
|
|
3
4
|
*
|
|
4
5
|
* The check resolves after the session started, so the header cannot be handed
|
|
5
6
|
* the value up front: it reads through this store on every apply, and the
|
|
@@ -16,6 +17,17 @@ export interface UpdateSource {
|
|
|
16
17
|
set(latest?: string): void;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* What one explicit re-check found. The background check reports nothing — nobody
|
|
22
|
+
* asked — so this vocabulary exists only for `/bytefly update`, where a failure or
|
|
23
|
+
* a skip is an answer rather than silence.
|
|
24
|
+
*/
|
|
25
|
+
export type UpdateOutcome =
|
|
26
|
+
| { kind: "offline" }
|
|
27
|
+
| { kind: "failed" }
|
|
28
|
+
| { kind: "current"; latest: string }
|
|
29
|
+
| { kind: "available"; latest: string };
|
|
30
|
+
|
|
19
31
|
/** A store holding at most one version. */
|
|
20
32
|
export function createUpdateState(): UpdateSource {
|
|
21
33
|
let latest: string | undefined;
|
|
@@ -124,11 +124,12 @@ merged over the defaults and clamped:
|
|
|
124
124
|
|---|---|---|---|
|
|
125
125
|
| `maxFileBytes` | `20971520` (20 MiB) | 1 KiB – 512 MiB | Reject larger files. |
|
|
126
126
|
| `maxChars` | `40000` | 200 – 100000 | Default slice size. |
|
|
127
|
+
| `extractTimeoutMs` | `30000` (30 s) | 1 s – 5 min | Per-call extraction deadline; a parser that outlasts it is abandoned. |
|
|
127
128
|
| `formats` | `{ "pdf": true, "docx": true, "doc": true, "odt": true, "rtf": true, "xlsx": true }` | — | Per-format on/off, merged over each registry entry's default; unknown keys are ignored. |
|
|
128
129
|
|
|
129
130
|
```jsonc
|
|
130
131
|
// bytefly.json → "document"
|
|
131
|
-
{ "maxChars": 80000, "formats": { "docx": false } }
|
|
132
|
+
{ "maxChars": 80000, "extractTimeoutMs": 60000, "formats": { "docx": false } }
|
|
132
133
|
```
|
|
133
134
|
|
|
134
135
|
`doc.json` is not read, and neither is the old per-extension `document.json`;
|
|
@@ -142,8 +143,9 @@ run `bun run config:migrate` to move a `document.json` into the shared file.
|
|
|
142
143
|
`resolveEffectiveCwd`; absolute paths are allowed only inside it. Containment
|
|
143
144
|
is symlink-aware (`isInsideReal`), and the real, resolved path is what the
|
|
144
145
|
parser receives.
|
|
145
|
-
- **Size-capped.** Files larger than `maxFileBytes` are rejected
|
|
146
|
-
parser runs
|
|
146
|
+
- **Size-capped and time-bounded.** Files larger than `maxFileBytes` are rejected
|
|
147
|
+
before any parser runs, and a parser that outlasts `extractTimeoutMs` is
|
|
148
|
+
abandoned so the turn is not held open by one document.
|
|
147
149
|
- **No subprocesses.** Extraction never spawns a process, so it grants no
|
|
148
150
|
privileges beyond the current process.
|
|
149
151
|
- **Untrusted text.** Extracted document text is treated as untrusted: it is
|
|
@@ -171,6 +173,10 @@ Each extractor builds a plain-text document from the format's bytes:
|
|
|
171
173
|
The tool is pageable across the whole extracted text, so a word-processing
|
|
172
174
|
document or workbook is read in the same `startIndex` / `maxChars` way as a PDF.
|
|
173
175
|
|
|
176
|
+
Each call is bounded by `extractTimeoutMs` (30 s by default). A parser that
|
|
177
|
+
outlasts the deadline is abandoned — the read fails with the limit named, and a
|
|
178
|
+
late result is discarded — so a pathological document cannot park the turn.
|
|
179
|
+
|
|
174
180
|
## Supported formats
|
|
175
181
|
|
|
176
182
|
| Format | Extensions | Extractor | Enabled by default |
|
|
@@ -203,13 +209,17 @@ config defaults, and error listing all derive from it.
|
|
|
203
209
|
|
|
204
210
|
- Parsing is best-effort and format-specific: output fidelity varies by library,
|
|
205
211
|
and a malformed file fails with that library's message.
|
|
206
|
-
- Legacy `.doc` relies on `word-extractor
|
|
207
|
-
|
|
212
|
+
- Legacy `.doc` relies on `word-extractor`, which now has a committed OLE fixture
|
|
213
|
+
(`test/helpers/fixtures/files/legacy.doc`, two paragraphs generated once by a
|
|
214
|
+
real word processor). Real Word 6/95 documents vary far more than one fixture
|
|
215
|
+
can show, so the format stays best-effort.
|
|
208
216
|
- No OCR: scanned or image-only PDFs return little or no text.
|
|
209
217
|
- Formatting, tables, and images are not preserved (XLSX keeps the row/column
|
|
210
218
|
shape but drops cell styling and formulas; DOCX/ODT keep table cells as tabs).
|
|
211
|
-
-
|
|
212
|
-
|
|
219
|
+
- The deadline stops waiting and discards the result; it cannot preempt a parser
|
|
220
|
+
that never yields to the event loop, because JavaScript cannot interrupt a
|
|
221
|
+
synchronous parse. A worker thread could; the package does not use one.
|
|
222
|
+
- `maxChars` limits the returned slice, not peak extraction memory.
|
|
213
223
|
- Only one file per call; legacy `.xls` gets a conversion hint.
|
|
214
224
|
|
|
215
225
|
## Non-goals
|
|
@@ -285,8 +295,8 @@ bun test extensions/document
|
|
|
285
295
|
```
|
|
286
296
|
|
|
287
297
|
Extractor tests run against real minimal files (`test/helpers/fixtures/documents.ts`
|
|
288
|
-
builds a genuine PDF, DOCX, and ODT; XLSX is written with `exceljs
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
`test/helpers/fixtures/document.ts`
|
|
292
|
-
symlink.
|
|
298
|
+
builds a genuine PDF, DOCX, and ODT; XLSX is written with `exceljs`; legacy `.doc`
|
|
299
|
+
reads the committed `files/legacy.doc`), so the libraries are exercised, not
|
|
300
|
+
stubbed. `tool.test.ts` injects fake extractors for behavioural cases and reads a
|
|
301
|
+
real PDF and a real `.doc` end to end; `test/helpers/fixtures/document.ts`
|
|
302
|
+
provides the scratch root and an escape symlink.
|
|
@@ -15,6 +15,8 @@ export interface DocConfig {
|
|
|
15
15
|
maxFileBytes: number;
|
|
16
16
|
/** Default character budget for the returned slice. */
|
|
17
17
|
maxChars: number;
|
|
18
|
+
/** Per-call extraction deadline; a parser that outlasts it is abandoned. */
|
|
19
|
+
extractTimeoutMs: number;
|
|
18
20
|
/** Per-format on/off, merged over each registry entry's `defaultEnabled`. */
|
|
19
21
|
formats: Record<FormatId, boolean>;
|
|
20
22
|
}
|
|
@@ -22,6 +24,7 @@ export interface DocConfig {
|
|
|
22
24
|
export const DEFAULT_CONFIG: DocConfig = {
|
|
23
25
|
maxFileBytes: 20 * 1024 * 1024,
|
|
24
26
|
maxChars: 40_000,
|
|
27
|
+
extractTimeoutMs: 30_000,
|
|
25
28
|
formats: Object.fromEntries(FORMATS.map((format) => [format.id, format.defaultEnabled])) as Record<FormatId, boolean>,
|
|
26
29
|
};
|
|
27
30
|
|
|
@@ -43,6 +46,7 @@ export function normalizeConfig(raw: Record<string, unknown> | undefined, base:
|
|
|
43
46
|
return {
|
|
44
47
|
maxFileBytes: clampInteger(raw.maxFileBytes, base.maxFileBytes, 1_024, 512 * 1024 * 1024),
|
|
45
48
|
maxChars: clampInteger(raw.maxChars, base.maxChars, 200, 100_000),
|
|
49
|
+
extractTimeoutMs: clampInteger(raw.extractTimeoutMs, base.extractTimeoutMs, 1_000, 300_000),
|
|
46
50
|
formats: normalizeFormats(raw.formats, base.formats),
|
|
47
51
|
};
|
|
48
52
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-call deadline for one in-process extraction.
|
|
3
|
+
*
|
|
4
|
+
* A parser is a library call, not a subprocess: JavaScript cannot preempt a
|
|
5
|
+
* synchronous parse, so the deadline stops *waiting* for the work and discards
|
|
6
|
+
* its result rather than killing it. What it guarantees is the turn: a
|
|
7
|
+
* pathological file fails with the limit named instead of parking the session,
|
|
8
|
+
* and a late settle from the abandoned promise never surfaces.
|
|
9
|
+
*
|
|
10
|
+
* The timer is a plain `setTimeout` cleared in `finally` (unlike
|
|
11
|
+
* `AbortSignal.timeout`, whose timer cannot be cancelled and would stay pending
|
|
12
|
+
* after every fast read). The deadline is deliberately not passed into `work`:
|
|
13
|
+
* the caller's abort signal is the only abort an extractor sees, so a timeout
|
|
14
|
+
* can never be reported as a turn abort or the reverse.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How the deadline reports, and when to give up. */
|
|
18
|
+
export interface DeadlineOptions {
|
|
19
|
+
/** Milliseconds to wait before abandoning the work. */
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
/** The error message a timeout throws; the caller names its own limit. */
|
|
22
|
+
timeoutMessage: string;
|
|
23
|
+
/** The turn's abort signal; an abort wins over the deadline. */
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run `work` with a deadline. Resolves with its value, rethrows its error
|
|
29
|
+
* unchanged, or rejects with `timeoutMessage` (or the caller's abort reason).
|
|
30
|
+
*/
|
|
31
|
+
export async function withDeadline<T>(work: () => Promise<T>, options: DeadlineOptions): Promise<T> {
|
|
32
|
+
const { timeoutMs, timeoutMessage, signal } = options;
|
|
33
|
+
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
const expiry = new Promise<never>((_resolve, reject) => {
|
|
36
|
+
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
37
|
+
// A pending timer must not keep a short-lived process alive.
|
|
38
|
+
(timer as { unref?: () => void }).unref?.();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let onAbort: (() => void) | undefined;
|
|
42
|
+
const aborted = signal
|
|
43
|
+
? new Promise<never>((_resolve, reject) => {
|
|
44
|
+
onAbort = () => reject(signal.reason ?? new Error("aborted"));
|
|
45
|
+
if (signal.aborted) onAbort();
|
|
46
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
47
|
+
})
|
|
48
|
+
: undefined;
|
|
49
|
+
|
|
50
|
+
const pending = work();
|
|
51
|
+
// One observed promise: if the deadline or the abort wins, a late resolve or
|
|
52
|
+
// rejection from the abandoned work has nobody left to reach.
|
|
53
|
+
pending.catch(() => {});
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
return await Promise.race(aborted ? [pending, expiry, aborted] : [pending, expiry]);
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -18,6 +18,7 @@ import { isInsideReal, realPathOfNearest } from "../../lib/path.ts";
|
|
|
18
18
|
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
|
+
import { withDeadline } from "./deadline.ts";
|
|
21
22
|
import type { DocExtractor } from "./extract/file.ts";
|
|
22
23
|
import { detectFormat, supportedExtensions, unsupportedHint, type FormatId } from "./formats.ts";
|
|
23
24
|
import { formatDoc } from "./paging.ts";
|
|
@@ -63,6 +64,8 @@ async function statFile(abs: string, display: string, maxFileBytes: number): Pro
|
|
|
63
64
|
/** Per-format extractor overrides, for tests; the format table supplies the rest. */
|
|
64
65
|
export interface DocToolDeps {
|
|
65
66
|
extractors?: Partial<Record<FormatId, DocExtractor>>;
|
|
67
|
+
/** Test seam: replace the configured `document.extractTimeoutMs`. */
|
|
68
|
+
timeoutMs?: number;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void {
|
|
@@ -71,7 +74,8 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
|
|
|
71
74
|
label: "Read document",
|
|
72
75
|
description:
|
|
73
76
|
"Read a local PDF, DOCX, DOC, ODT, RTF, or XLSX file and return its extracted text, pageable with " +
|
|
74
|
-
"startIndex/maxChars. Extraction runs in process, so no external tools are needed
|
|
77
|
+
"startIndex/maxChars. Extraction runs in process, so no external tools are needed; a read that outlasts " +
|
|
78
|
+
"document.extractTimeoutMs fails with the limit named.",
|
|
75
79
|
promptSnippet: "Read a PDF, DOCX, DOC, ODT, RTF, or XLSX as text, paged with startIndex/maxChars.",
|
|
76
80
|
promptGuidelines: [
|
|
77
81
|
"Use read_doc for .pdf/.docx/.doc/.odt/.rtf/.xlsx; the built-in read tool is for text and images.",
|
|
@@ -101,8 +105,15 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
|
|
|
101
105
|
|
|
102
106
|
const size = await statFile(abs, display, config.maxFileBytes);
|
|
103
107
|
const extractor = deps.extractors?.[format.id] ?? format.extract;
|
|
108
|
+
const timeoutMs = deps.timeoutMs ?? config.extractTimeoutMs;
|
|
104
109
|
startToolProgress(onUpdate);
|
|
105
|
-
const text = await extractor({ path: abs, signal })
|
|
110
|
+
const text = await withDeadline(() => extractor({ path: abs, signal }), {
|
|
111
|
+
timeoutMs,
|
|
112
|
+
signal,
|
|
113
|
+
timeoutMessage:
|
|
114
|
+
`Extraction of ${display} did not finish within ${timeoutMs} ms (document.extractTimeoutMs). ` +
|
|
115
|
+
`The ${format.label} parser was abandoned; raise the limit or read a smaller file.`,
|
|
116
|
+
});
|
|
106
117
|
|
|
107
118
|
const startIndex = typeof args.startIndex === "number" ? Math.max(0, Math.round(args.startIndex)) : 0;
|
|
108
119
|
const requested = typeof args.maxChars === "number" ? args.maxChars : config.maxChars;
|
|
@@ -22,8 +22,9 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
|
|
|
22
22
|
opens the browser (unless `autoOpen: false`).
|
|
23
23
|
- **Two-pane UI.** A server-rendered tree, directory listings, and file pages,
|
|
24
24
|
progressively enhanced by one first-party script (filter, lazy tree
|
|
25
|
-
expansion, thumbnail fallback).
|
|
26
|
-
|
|
25
|
+
expansion, thumbnail fallback). The tree scrolls in its own pane with quick
|
|
26
|
+
open and the filter pinned above it; below 860px it collapses into a `Files`
|
|
27
|
+
disclosure above the listing. A hairline progress bar tracks whichever
|
|
27
28
|
column is scrolling (the card body for a file or diff, the main column
|
|
28
29
|
otherwise), and the served folder names the root page.
|
|
29
30
|
- **Rich previews.** Text, rendered Markdown, images (thumbnails), and
|
|
@@ -222,11 +223,14 @@ document and the project document (project wins):
|
|
|
222
223
|
non-matching rows while keeping ancestors, disclosure triangles lazily
|
|
223
224
|
`fetch("/api/tree")`, broken thumbnails fall back to the category icon, the
|
|
224
225
|
listing headers sort in place, the wrap toggle is remembered, and the copy
|
|
225
|
-
buttons use the clipboard.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
buttons use the clipboard. On wide screens the tree is its own scroll pane
|
|
227
|
+
under the pinned filter and quick-open trigger; the sidebar is a `<details>`
|
|
228
|
+
whose content wrapper has no definite height, so the panel fills it
|
|
229
|
+
absolutely and the tree flexes inside. Without JS the page still browses and
|
|
230
|
+
renders Markdown, and the JS-only controls (filter, disclosure, actions, quick
|
|
231
|
+
open) are hidden; the sort buttons lose their pointer so they do not look
|
|
232
|
+
live. The palette is the shared Bytefly web theme (`lib/web/tokens.ts`),
|
|
233
|
+
derived from the `bytefly-dark`/`bytefly-light` terminal themes; it follows
|
|
230
234
|
`prefers-color-scheme` until an explicit choice sets `data-theme` on the root:
|
|
231
235
|
`/theme.js` applies the saved choice in the head before first paint, and the
|
|
232
236
|
header toggle flips and stores it (`pi-web-theme` in `localStorage`, the key
|
|
@@ -273,7 +277,7 @@ document and the project document (project wins):
|
|
|
273
277
|
`--cached` before the first commit), `staged` against the index, and
|
|
274
278
|
`worktree` against the index from the other side. An untracked file renders as
|
|
275
279
|
all-add through `readFileView`, so binary and over-size handling is shared
|
|
276
|
-
with the file view. `git diff --
|
|
280
|
+
with the file view. `git diff --stat` supplies the chip totals and
|
|
277
281
|
`changeDirs` derives the directory markers; the changed lines are highlighted
|
|
278
282
|
per hunk by rebuilding the old and new sides for `lib/highlight`.
|
|
279
283
|
- **Streaming responses.** The server streams `filePath` responses and
|