pim-agent 0.0.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -7
- package/bin/pim.ts +151 -0
- package/package.json +82 -4
- package/packages/core/src/attachments/AttachmentStore.ts +99 -0
- package/packages/core/src/attachments/Attachments.ts +69 -0
- package/packages/core/src/extensions/CoreExtensions.ts +54 -0
- package/packages/core/src/extensions/apply-patch/coordinator.ts +53 -0
- package/packages/core/src/extensions/apply-patch/executor.ts +550 -0
- package/packages/core/src/extensions/apply-patch/index.ts +69 -0
- package/packages/core/src/extensions/apply-patch/matcher.ts +47 -0
- package/packages/core/src/extensions/apply-patch/model.ts +39 -0
- package/packages/core/src/extensions/apply-patch/parser.ts +379 -0
- package/packages/core/src/extensions/apply-patch/render.ts +177 -0
- package/packages/core/src/extensions/apply-patch/schema.ts +37 -0
- package/packages/core/src/extensions/apply-patch/types.ts +30 -0
- package/packages/core/src/extensions/bash/capture.ts +86 -0
- package/packages/core/src/extensions/bash/format.ts +110 -0
- package/packages/core/src/extensions/bash/image.ts +31 -0
- package/packages/core/src/extensions/bash/index.ts +88 -0
- package/packages/core/src/extensions/bash/render.ts +31 -0
- package/packages/core/src/extensions/bash/run.ts +221 -0
- package/packages/core/src/extensions/bash/schema.ts +64 -0
- package/packages/core/src/extensions/edit/edit.ts +366 -0
- package/packages/core/src/extensions/edit/index.ts +36 -0
- package/packages/core/src/extensions/edit/render.ts +16 -0
- package/packages/core/src/extensions/edit/schema.ts +37 -0
- package/packages/core/src/extensions/glob/glob.ts +34 -0
- package/packages/core/src/extensions/glob/index.ts +83 -0
- package/packages/core/src/extensions/glob/render.ts +72 -0
- package/packages/core/src/extensions/glob/schema.ts +66 -0
- package/packages/core/src/extensions/grep/grep.ts +256 -0
- package/packages/core/src/extensions/grep/index.ts +114 -0
- package/packages/core/src/extensions/grep/render.ts +271 -0
- package/packages/core/src/extensions/grep/schema.ts +92 -0
- package/packages/core/src/extensions/read/ImageMemory.ts +42 -0
- package/packages/core/src/extensions/read/image.ts +57 -0
- package/packages/core/src/extensions/read/index.ts +88 -0
- package/packages/core/src/extensions/read/read.ts +191 -0
- package/packages/core/src/extensions/read/render.ts +58 -0
- package/packages/core/src/extensions/read/schema.ts +49 -0
- package/packages/core/src/extensions/subagent/index.ts +33 -0
- package/packages/core/src/extensions/subagent/render.ts +127 -0
- package/packages/core/src/extensions/subagent/schema.ts +9 -0
- package/packages/core/src/extensions/subagent/subagent.ts +419 -0
- package/packages/core/src/extensions/system-prompt/index.ts +36 -0
- package/packages/core/src/extensions/system-prompt/prompt.ts +232 -0
- package/packages/core/src/extensions/todo/index.ts +123 -0
- package/packages/core/src/extensions/todo/render.ts +156 -0
- package/packages/core/src/extensions/todo/schema.ts +24 -0
- package/packages/core/src/extensions/todo/todo.ts +185 -0
- package/packages/core/src/extensions/web-fetch/JinaReaderClient.ts +187 -0
- package/packages/core/src/extensions/web-fetch/WebViewFetchClient.ts +183 -0
- package/packages/core/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +512 -0
- package/packages/core/src/extensions/web-fetch/fetch.ts +265 -0
- package/packages/core/src/extensions/web-fetch/image.ts +165 -0
- package/packages/core/src/extensions/web-fetch/index.ts +83 -0
- package/packages/core/src/extensions/web-fetch/render.ts +100 -0
- package/packages/core/src/extensions/web-fetch/schema.ts +46 -0
- package/packages/core/src/extensions/web-search/ExaMcpClient.ts +251 -0
- package/packages/core/src/extensions/web-search/SearchBreaker.ts +147 -0
- package/packages/core/src/extensions/web-search/SearchChain.ts +77 -0
- package/packages/core/src/extensions/web-search/index.ts +90 -0
- package/packages/core/src/extensions/web-search/providers/DuckDuckGoProvider.ts +195 -0
- package/packages/core/src/extensions/web-search/providers/ExaProvider.ts +54 -0
- package/packages/core/src/extensions/web-search/providers/FirecrawlProvider.ts +144 -0
- package/packages/core/src/extensions/web-search/providers/SearchProvider.ts +118 -0
- package/packages/core/src/extensions/web-search/render.ts +49 -0
- package/packages/core/src/extensions/web-search/schema.ts +29 -0
- package/packages/core/src/extensions/web-search/search.ts +23 -0
- package/packages/core/src/extensions/write/index.ts +58 -0
- package/packages/core/src/extensions/write/render.ts +16 -0
- package/packages/core/src/extensions/write/schema.ts +14 -0
- package/packages/core/src/extensions/write/write.ts +87 -0
- package/packages/core/src/picker/FilePickerSuggestionEngine.ts +14 -0
- package/packages/core/src/picker/InProcessFilePickerSuggestionEngine.ts +52 -0
- package/packages/core/src/picker/PickerItem.ts +6 -0
- package/packages/core/src/picker/PickerService.ts +132 -0
- package/packages/core/src/picker/RemoteFilePickerSuggestionEngine.ts +56 -0
- package/packages/core/src/picker/WorkerFilePickerSuggestionEngine.ts +268 -0
- package/packages/core/src/picker/catalog.ts +220 -0
- package/packages/core/src/picker/commandRanker.ts +17 -0
- package/packages/core/src/picker/filePickerWorker.ts +66 -0
- package/packages/core/src/picker/filePickerWorkerMessages.ts +39 -0
- package/packages/core/src/picker/ranker.ts +245 -0
- package/packages/core/src/picker/token.ts +77 -0
- package/packages/core/src/session/AgentRuntime.ts +85 -0
- package/packages/core/src/session/EventLog.ts +156 -0
- package/packages/core/src/session/MessageText.ts +21 -0
- package/packages/core/src/session/ReadCursors.ts +94 -0
- package/packages/core/src/session/SearchIndex.ts +752 -0
- package/packages/core/src/session/SearchTokens.ts +97 -0
- package/packages/core/src/session/SessionCache.ts +68 -0
- package/packages/core/src/session/SessionDigest.ts +161 -0
- package/packages/core/src/session/SessionHost.ts +892 -0
- package/packages/core/src/session/SessionLease.ts +279 -0
- package/packages/core/src/session/SessionMeta.ts +267 -0
- package/packages/core/src/session/SessionName.ts +18 -0
- package/packages/core/src/session/SessionRegistry.ts +244 -0
- package/packages/core/src/session/WriteMark.ts +51 -0
- package/packages/core/src/shared/Cli.ts +25 -0
- package/packages/core/src/shared/DaemonUnit.ts +13 -0
- package/packages/core/src/shared/DiffLines.ts +304 -0
- package/packages/core/src/shared/DiffPatch.ts +70 -0
- package/packages/core/src/shared/DiffRenderer.ts +283 -0
- package/packages/core/src/shared/DiffView.ts +29 -0
- package/packages/core/src/shared/Directories.ts +61 -0
- package/packages/core/src/shared/EditMatcher.ts +767 -0
- package/packages/core/src/shared/Errors.ts +9 -0
- package/packages/core/src/shared/ExtensionToggles.ts +130 -0
- package/packages/core/src/shared/FileEnumerator.ts +361 -0
- package/packages/core/src/shared/FileScanner.ts +57 -0
- package/packages/core/src/shared/FileWatch.ts +121 -0
- package/packages/core/src/shared/Format.ts +74 -0
- package/packages/core/src/shared/Fs.ts +91 -0
- package/packages/core/src/shared/FsErrors.ts +67 -0
- package/packages/core/src/shared/FuzzyMatcher.ts +71 -0
- package/packages/core/src/shared/Git.ts +530 -0
- package/packages/core/src/shared/GitMonitor.ts +221 -0
- package/packages/core/src/shared/GlobExclusions.ts +13 -0
- package/packages/core/src/shared/Http.ts +14 -0
- package/packages/core/src/shared/ImageMime.ts +36 -0
- package/packages/core/src/shared/Images.ts +357 -0
- package/packages/core/src/shared/Json.ts +19 -0
- package/packages/core/src/shared/Languages.ts +95 -0
- package/packages/core/src/shared/Levenshtein.ts +83 -0
- package/packages/core/src/shared/Lines.ts +79 -0
- package/packages/core/src/shared/McpClient.ts +444 -0
- package/packages/core/src/shared/OutputBudget.ts +84 -0
- package/packages/core/src/shared/PatchSummary.ts +40 -0
- package/packages/core/src/shared/Paths.ts +80 -0
- package/packages/core/src/shared/PimSettings.ts +128 -0
- package/packages/core/src/shared/PimVersion.ts +93 -0
- package/packages/core/src/shared/Pool.ts +26 -0
- package/packages/core/src/shared/Proc.ts +50 -0
- package/packages/core/src/shared/RateLimiter.ts +50 -0
- package/packages/core/src/shared/Renderer.ts +371 -0
- package/packages/core/src/shared/RepoDiff.ts +741 -0
- package/packages/core/src/shared/SafePath.ts +17 -0
- package/packages/core/src/shared/SearchRender.ts +82 -0
- package/packages/core/src/shared/SendFile.ts +23 -0
- package/packages/core/src/shared/SpillCache.ts +78 -0
- package/packages/core/src/shared/SubagentLogs.ts +102 -0
- package/packages/core/src/shared/Supervisor.ts +460 -0
- package/packages/core/src/shared/Surface.ts +2 -0
- package/packages/core/src/shared/Sweeper.ts +32 -0
- package/packages/core/src/shared/Tools.ts +801 -0
- package/packages/core/src/shared/Updater.ts +209 -0
- package/packages/core/src/view/AnsiPainter.ts +227 -0
- package/packages/core/src/view/BodyRenderer.ts +102 -0
- package/packages/core/src/view/DiffBlocks.ts +52 -0
- package/packages/core/src/view/DiffExpand.ts +214 -0
- package/packages/core/src/view/DiffLayout.ts +69 -0
- package/packages/core/src/view/DiffPairs.ts +47 -0
- package/packages/core/src/view/MarkdownPainter.ts +314 -0
- package/packages/core/src/view/MovePath.ts +70 -0
- package/packages/core/src/view/Painting.ts +182 -0
- package/packages/core/src/view/ViewBlock.ts +124 -0
- package/packages/daemon/src/Daemon.ts +63 -0
- package/packages/daemon/src/DaemonInstall.ts +115 -0
- package/packages/daemon/src/Surfaces.ts +66 -0
- package/packages/daemon/src/TelegramSurface.ts +30 -0
- package/packages/daemon/src/WebSurface.ts +56 -0
- package/packages/daemon/src/index.ts +37 -0
- package/packages/protocol/src/Command.ts +219 -0
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ImageRoute.ts +23 -0
- package/packages/protocol/src/ServerEvent.ts +406 -0
- package/packages/server/src/AttachmentEndpoint.ts +184 -0
- package/packages/server/src/ClientConnection.ts +277 -0
- package/packages/server/src/ImageEndpoint.ts +36 -0
- package/packages/server/src/ProbeClient.ts +374 -0
- package/packages/server/src/Reloader.ts +93 -0
- package/packages/server/src/SendFileTool.ts +88 -0
- package/packages/server/src/SessionCatalogue.ts +456 -0
- package/packages/server/src/SessionProjection.ts +194 -0
- package/packages/server/src/SessionStream.ts +627 -0
- package/packages/server/src/StaticClient.ts +178 -0
- package/packages/server/src/WebOptions.ts +72 -0
- package/packages/server/src/WsGateway.ts +798 -0
- package/packages/server/src/probe.ts +200 -0
- package/packages/telegram/src/Bot.ts +229 -0
- package/packages/telegram/src/Commands.ts +768 -0
- package/packages/telegram/src/Config.ts +148 -0
- package/packages/telegram/src/Markdown.ts +235 -0
- package/packages/telegram/src/Message.ts +152 -0
- package/packages/telegram/src/Renderer.ts +456 -0
- package/packages/telegram/src/SendFileSchema.ts +18 -0
- package/packages/telegram/src/SendFileTool.ts +65 -0
- package/packages/telegram/src/Session.ts +253 -0
- package/packages/telegram/src/SessionRegistry.ts +126 -0
- package/packages/telegram/src/TaskScheduler.ts +296 -0
- package/packages/telegram/src/TaskSchema.ts +88 -0
- package/packages/telegram/src/TaskStore.ts +67 -0
- package/packages/telegram/src/TaskTool.ts +208 -0
- package/packages/telegram/src/TelegramHtml.ts +288 -0
- package/packages/telegram/src/TypingIndicator.ts +43 -0
- package/packages/telegram/src/UpdateConfirm.ts +61 -0
- package/packages/tui/src/extensions/_init/index.ts +144 -0
- package/packages/tui/src/extensions/command-picker/index.ts +75 -0
- package/packages/tui/src/extensions/file-picker/index.ts +156 -0
- package/packages/tui/src/extensions/footer/index.ts +93 -0
- package/packages/tui/src/extensions/footer/powerline.ts +87 -0
- package/packages/tui/src/extensions/footer/segments.ts +202 -0
- package/packages/tui/src/extensions/pim/index.ts +89 -0
- package/packages/tui/src/extensions/session-lease/index.ts +253 -0
- package/packages/tui/src/extensions/tps/index.ts +118 -0
- package/packages/tui/src/extensions/working-indicator/index.ts +75 -0
- package/packages/tui/src/extensions/wrapProvider.ts +18 -0
- package/packages/tui/src/themes/pim-dark.json +84 -0
- package/packages/tui/src/themes/pim-light.json +84 -0
- package/packages/tui/src/themes/themeCliArgs.ts +20 -0
- package/packages/web/dist/client/apple-touch-icon.png +0 -0
- package/packages/web/dist/client/assets/bash-LmRjUKlR.js +1 -0
- package/packages/web/dist/client/assets/c-DBVbJFpC.js +1 -0
- package/packages/web/dist/client/assets/clojure-CR7dvLJ4.js +1 -0
- package/packages/web/dist/client/assets/cmake-CgP6gB4W.js +1 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/core-CA0aSzPu.js +3 -0
- package/packages/web/dist/client/assets/cpp-N2d8M7Nc.js +1 -0
- package/packages/web/dist/client/assets/csharp-yYt0Ikks.js +1 -0
- package/packages/web/dist/client/assets/css-D8M8GYaw.js +1 -0
- package/packages/web/dist/client/assets/diff-BhdZizse.js +1 -0
- package/packages/web/dist/client/assets/dockerfile-C0zufGsU.js +1 -0
- package/packages/web/dist/client/assets/elixir-DXIHlGsR.js +1 -0
- package/packages/web/dist/client/assets/erlang-BjYI8dhz.js +1 -0
- package/packages/web/dist/client/assets/go-CYstVVJV.js +1 -0
- package/packages/web/dist/client/assets/graphql-BHHhacaR.js +1 -0
- package/packages/web/dist/client/assets/haskell-DUkhXIda.js +1 -0
- package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
- package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
- package/packages/web/dist/client/assets/ini-B1CroMsf.js +1 -0
- package/packages/web/dist/client/assets/java-Dde-Wnb4.js +1 -0
- package/packages/web/dist/client/assets/javascript-8AmHuH5O.js +1 -0
- package/packages/web/dist/client/assets/json-PmyKWHmm.js +1 -0
- package/packages/web/dist/client/assets/kotlin-DgSK8bu-.js +2 -0
- package/packages/web/dist/client/assets/less-Bp0mgmpG.js +1 -0
- package/packages/web/dist/client/assets/lua-1dXYaL-H.js +1 -0
- package/packages/web/dist/client/assets/makefile-BX-o3EIz.js +1 -0
- package/packages/web/dist/client/assets/markdown-D6FrnlqJ.js +1 -0
- package/packages/web/dist/client/assets/ocaml-B-P1Uyq8.js +1 -0
- package/packages/web/dist/client/assets/perl-CvqMZYuf.js +1 -0
- package/packages/web/dist/client/assets/php-DuZl5e-L.js +2 -0
- package/packages/web/dist/client/assets/powershell-BUe16zE4.js +1 -0
- package/packages/web/dist/client/assets/protobuf-DGOW1qT8.js +1 -0
- package/packages/web/dist/client/assets/python-gHmu6VPG.js +1 -0
- package/packages/web/dist/client/assets/r-DNWPCnjT.js +1 -0
- package/packages/web/dist/client/assets/ruby-BGpZ3p4_.js +1 -0
- package/packages/web/dist/client/assets/rust-BIKPVoi6.js +1 -0
- package/packages/web/dist/client/assets/scala-C9lP4FWY.js +1 -0
- package/packages/web/dist/client/assets/scss-gOyOKiXd.js +1 -0
- package/packages/web/dist/client/assets/sql-DWRKyvJ-.js +1 -0
- package/packages/web/dist/client/assets/swift-BTn9__hL.js +1 -0
- package/packages/web/dist/client/assets/typescript-CxkPLmSc.js +1 -0
- package/packages/web/dist/client/assets/vim-Dhhcn_CA.js +1 -0
- package/packages/web/dist/client/assets/xml-COl5yFSC.js +1 -0
- package/packages/web/dist/client/assets/yaml-DjOhhVLG.js +1 -0
- package/packages/web/dist/client/favicon.svg +1 -0
- package/packages/web/dist/client/icon-192.png +0 -0
- package/packages/web/dist/client/icon-512.png +0 -0
- package/packages/web/dist/client/icon-maskable.png +0 -0
- package/packages/web/dist/client/index.html +24 -0
- package/packages/web/dist/client/manifest.webmanifest +21 -0
- package/packages/web/dist/client/wordmark.svg +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { FileWatch } from "./FileWatch";
|
|
4
|
+
import { Git, type GitOutcome, type GitState } from "./Git";
|
|
5
|
+
|
|
6
|
+
export type GitListener = (state: GitState) => void;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What one operation under the lock says, and what it made where it made
|
|
10
|
+
* anything: a commit has a sha to hand back, a checkout has only its outcome.
|
|
11
|
+
*/
|
|
12
|
+
export type GitRun<T = never> = GitOutcome & { readonly value?: T };
|
|
13
|
+
|
|
14
|
+
export type GitMonitorDeps = {
|
|
15
|
+
/** How long a fetch stands before another is worth its round trip. */
|
|
16
|
+
readonly fetchTtlMs?: number;
|
|
17
|
+
/** How often a watched repository is read for the edits `.git` never hears about. */
|
|
18
|
+
readonly pollMs?: number;
|
|
19
|
+
readonly status?: (cwd: string) => Promise<GitState>;
|
|
20
|
+
readonly fetch?: (cwd: string) => Promise<GitOutcome>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Entry = {
|
|
24
|
+
readonly cwd: string;
|
|
25
|
+
readonly listeners: Set<GitListener>;
|
|
26
|
+
state: GitState;
|
|
27
|
+
stop: (() => void) | undefined;
|
|
28
|
+
inFlight: Promise<GitState> | undefined;
|
|
29
|
+
pending: boolean;
|
|
30
|
+
fetchedAt: number;
|
|
31
|
+
locked: boolean;
|
|
32
|
+
/** How long the last read took, and when it ended: what the poll paces itself by. */
|
|
33
|
+
readMs: number;
|
|
34
|
+
readAt: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A checkout writes half the repository; one read has to stand for the burst. */
|
|
38
|
+
const DEBOUNCE_MS = 200;
|
|
39
|
+
|
|
40
|
+
const FETCH_TTL_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
/** A worktree edit touches nothing under `.git`, so the watch alone would never see it. */
|
|
43
|
+
const POLL_MS = 2_000;
|
|
44
|
+
|
|
45
|
+
/** A repository too big to read in a tick is read this many times its own cost apart. */
|
|
46
|
+
const BACKOFF = 4;
|
|
47
|
+
|
|
48
|
+
function same(a: GitState, b: GitState): boolean {
|
|
49
|
+
return (
|
|
50
|
+
a.branch === b.branch &&
|
|
51
|
+
a.dirtyCount === b.dirtyCount &&
|
|
52
|
+
a.ahead === b.ahead &&
|
|
53
|
+
a.behind === b.behind &&
|
|
54
|
+
a.revision === b.revision
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One reader of one repository, however many sessions share it: it watches
|
|
60
|
+
* `.git` so a commit anywhere reaches every surface, coalesces the burst a
|
|
61
|
+
* checkout makes into a single read, and serialises the operations that write,
|
|
62
|
+
* which cannot run two at a time over one index.
|
|
63
|
+
*/
|
|
64
|
+
export class GitMonitor {
|
|
65
|
+
private readonly entries = new Map<string, Entry>();
|
|
66
|
+
private readonly deps: GitMonitorDeps;
|
|
67
|
+
|
|
68
|
+
public constructor(deps: GitMonitorDeps = {}) {
|
|
69
|
+
this.deps = deps;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Follows `cwd` until the returned stop is called; the listener hears only changes, after a read of its own. */
|
|
73
|
+
public watch(cwd: string, listener: GitListener): () => void {
|
|
74
|
+
const entry = this.entryOf(cwd);
|
|
75
|
+
entry.listeners.add(listener);
|
|
76
|
+
entry.stop ??= this.follow(entry);
|
|
77
|
+
void this.read(entry, false);
|
|
78
|
+
return () => {
|
|
79
|
+
entry.listeners.delete(listener);
|
|
80
|
+
if (entry.listeners.size === 0) {
|
|
81
|
+
entry.stop?.();
|
|
82
|
+
entry.stop = undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The last state read, without waiting: a caller painting a frame takes what is known now. */
|
|
88
|
+
public stateOf(cwd: string): GitState {
|
|
89
|
+
return this.entries.get(cwd)?.state ?? Git.EMPTY;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Re-reads now; `fetch` also asks the remote, which is what ahead and behind are counted against. */
|
|
93
|
+
public async refresh(
|
|
94
|
+
cwd: string,
|
|
95
|
+
options: { readonly fetch?: boolean } = {}
|
|
96
|
+
): Promise<GitState> {
|
|
97
|
+
const entry = this.entryOf(cwd);
|
|
98
|
+
const ttl = this.deps.fetchTtlMs ?? FETCH_TTL_MS;
|
|
99
|
+
if (
|
|
100
|
+
options.fetch === true &&
|
|
101
|
+
!entry.locked &&
|
|
102
|
+
Date.now() - entry.fetchedAt > ttl
|
|
103
|
+
) {
|
|
104
|
+
entry.fetchedAt = Date.now();
|
|
105
|
+
await (this.deps.fetch ?? Git.fetch)(cwd).catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
return await this.read(entry);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Runs one writing operation over `cwd`, refusing a second while it lasts and re-reading once it ends. */
|
|
111
|
+
public async run<T = never>(
|
|
112
|
+
cwd: string,
|
|
113
|
+
operation: () => Promise<GitRun<T>>
|
|
114
|
+
): Promise<GitRun<T>> {
|
|
115
|
+
const entry = this.entryOf(cwd);
|
|
116
|
+
if (entry.locked) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: "another git operation is already running in this directory",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
entry.locked = true;
|
|
123
|
+
try {
|
|
124
|
+
return await operation();
|
|
125
|
+
} finally {
|
|
126
|
+
entry.locked = false;
|
|
127
|
+
await this.read(entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public dispose(): void {
|
|
132
|
+
for (const entry of this.entries.values()) {
|
|
133
|
+
entry.stop?.();
|
|
134
|
+
}
|
|
135
|
+
this.entries.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private entryOf(cwd: string): Entry {
|
|
139
|
+
const found = this.entries.get(cwd);
|
|
140
|
+
if (found) {
|
|
141
|
+
return found;
|
|
142
|
+
}
|
|
143
|
+
const entry: Entry = {
|
|
144
|
+
cwd,
|
|
145
|
+
listeners: new Set(),
|
|
146
|
+
state: Git.EMPTY,
|
|
147
|
+
stop: undefined,
|
|
148
|
+
inFlight: undefined,
|
|
149
|
+
pending: false,
|
|
150
|
+
fetchedAt: 0,
|
|
151
|
+
locked: false,
|
|
152
|
+
readMs: 0,
|
|
153
|
+
readAt: 0,
|
|
154
|
+
};
|
|
155
|
+
this.entries.set(cwd, entry);
|
|
156
|
+
return entry;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private follow(entry: Entry): () => void {
|
|
160
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
161
|
+
const stop = FileWatch.directory(join(entry.cwd, ".git"), () => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
timer = setTimeout(() => {
|
|
164
|
+
void this.read(entry);
|
|
165
|
+
}, DEBOUNCE_MS);
|
|
166
|
+
timer.unref?.();
|
|
167
|
+
});
|
|
168
|
+
// `false`: a read already running is fresh enough for a tick, and a slow
|
|
169
|
+
// status must never queue another behind itself.
|
|
170
|
+
const poll = setInterval(() => {
|
|
171
|
+
if (Date.now() - entry.readAt < entry.readMs * BACKOFF) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
void this.read(entry, false);
|
|
175
|
+
}, this.deps.pollMs ?? POLL_MS);
|
|
176
|
+
poll.unref?.();
|
|
177
|
+
return () => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
clearInterval(poll);
|
|
180
|
+
stop();
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `restart` re-reads behind a read already running: a change cannot be answered by a read that predates it, a new subscriber can. */
|
|
185
|
+
private read(entry: Entry, restart = true): Promise<GitState> {
|
|
186
|
+
if (entry.inFlight) {
|
|
187
|
+
entry.pending ||= restart;
|
|
188
|
+
return entry.inFlight;
|
|
189
|
+
}
|
|
190
|
+
const run = (async (): Promise<GitState> => {
|
|
191
|
+
const started = Date.now();
|
|
192
|
+
try {
|
|
193
|
+
let next = entry.state;
|
|
194
|
+
do {
|
|
195
|
+
entry.pending = false;
|
|
196
|
+
next = await (this.deps.status ?? Git.fetchStatus)(entry.cwd);
|
|
197
|
+
} while (entry.pending);
|
|
198
|
+
this.settle(entry, next);
|
|
199
|
+
return next;
|
|
200
|
+
} catch {
|
|
201
|
+
return entry.state;
|
|
202
|
+
} finally {
|
|
203
|
+
entry.inFlight = undefined;
|
|
204
|
+
entry.readAt = Date.now();
|
|
205
|
+
entry.readMs = entry.readAt - started;
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
208
|
+
entry.inFlight = run;
|
|
209
|
+
return run;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private settle(entry: Entry, next: GitState): void {
|
|
213
|
+
if (same(entry.state, next)) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
entry.state = next;
|
|
217
|
+
for (const listener of entry.listeners) {
|
|
218
|
+
listener(next);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function compile(exclude: readonly string[] | undefined): readonly Bun.Glob[] {
|
|
2
|
+
return (exclude ?? []).map((pattern) => new Bun.Glob(pattern));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function ignores(globs: readonly Bun.Glob[], relativePath: string): boolean {
|
|
6
|
+
if (globs.length === 0) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return globs.some((glob) => glob.match(relativePath));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const GlobExclusions = { compile, ignores };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ky, { type KyInstance } from "ky";
|
|
2
|
+
|
|
3
|
+
export type HttpFetch = (
|
|
4
|
+
input: Parameters<typeof fetch>[0],
|
|
5
|
+
init?: Parameters<typeof fetch>[1]
|
|
6
|
+
) => ReturnType<typeof fetch>;
|
|
7
|
+
|
|
8
|
+
/** Ky with retries off: its defaults retry GETs twice and honour `Retry-After` uncapped, stalling a tool for hours. */
|
|
9
|
+
export function createKy(custom?: HttpFetch): KyInstance {
|
|
10
|
+
return ky.create({
|
|
11
|
+
retry: 0,
|
|
12
|
+
...(custom === undefined ? {} : { fetch: custom as typeof fetch }),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The formats every provider we target accepts. */
|
|
2
|
+
export type ImageMimeType =
|
|
3
|
+
| "image/png"
|
|
4
|
+
| "image/jpeg"
|
|
5
|
+
| "image/gif"
|
|
6
|
+
| "image/webp";
|
|
7
|
+
|
|
8
|
+
const EXTENSIONS = {
|
|
9
|
+
"image/png": "png",
|
|
10
|
+
"image/jpeg": "jpg",
|
|
11
|
+
"image/gif": "gif",
|
|
12
|
+
"image/webp": "webp",
|
|
13
|
+
} satisfies Record<ImageMimeType, string>;
|
|
14
|
+
|
|
15
|
+
/** Both spellings of every format, so `.jpeg` names a picture as surely as `.jpg`. */
|
|
16
|
+
const NAME_EXTENSIONS = new Set([
|
|
17
|
+
...Object.values(EXTENSIONS),
|
|
18
|
+
...Object.keys(EXTENSIONS).map((mimeType) => mimeType.slice("image/".length)),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** The format as a word, in the one spelling the notes, the UI and the cache filename all use. */
|
|
22
|
+
function extensionOf(mimeType: ImageMimeType): string {
|
|
23
|
+
return EXTENSIONS[mimeType];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isSupported(mimeType: string): mimeType is ImageMimeType {
|
|
27
|
+
return Object.hasOwn(EXTENSIONS, mimeType);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether a bare, lowercased extension is one of the spellings a picture goes by. */
|
|
31
|
+
function namesExtension(extension: string): boolean {
|
|
32
|
+
return NAME_EXTENSIONS.has(extension);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The format table alone, free of Node so the browser can name a picture too. */
|
|
36
|
+
export const ImageMime = { extensionOf, isSupported, namesExtension };
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
resizeImage,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { extname } from "node:path";
|
|
6
|
+
import { Format } from "./Format";
|
|
7
|
+
import { ImageMime, type ImageMimeType } from "./ImageMime";
|
|
8
|
+
import { Lines } from "./Lines";
|
|
9
|
+
import { SpillCache } from "./SpillCache";
|
|
10
|
+
|
|
11
|
+
export type { ImageMimeType };
|
|
12
|
+
|
|
13
|
+
/** What a tool needs off `ctx.model` to know whether a picture can be sent. */
|
|
14
|
+
export type VisionModel = {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly input: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** An image the providers will take: within their formats, dimensions and byte budget. */
|
|
20
|
+
export type NormalisedImage = {
|
|
21
|
+
readonly base64: string;
|
|
22
|
+
readonly mimeType: ImageMimeType;
|
|
23
|
+
readonly width: number;
|
|
24
|
+
readonly height: number;
|
|
25
|
+
/** Encoded size after the resize, which is what the cache holds. */
|
|
26
|
+
readonly bytes: number;
|
|
27
|
+
readonly originalWidth: number;
|
|
28
|
+
readonly originalHeight: number;
|
|
29
|
+
/** The container the frames were counted in: a resize is free to re-encode as another. */
|
|
30
|
+
readonly originalMimeType: ImageMimeType;
|
|
31
|
+
readonly resized: boolean;
|
|
32
|
+
/** Frames in the source container, 1 for a still: the encoded copy holds only the first. */
|
|
33
|
+
readonly frames: number;
|
|
34
|
+
readonly sha256: string;
|
|
35
|
+
/** `~/.pim/cache/img-<sha256>.<ext>`, or null when the cache write failed. */
|
|
36
|
+
readonly cachePath: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** What every tool records about a picture it showed: the picture itself never rides here, `sha256` addresses it in the spill cache. */
|
|
40
|
+
export type ImageDetails = {
|
|
41
|
+
readonly sha256: string;
|
|
42
|
+
readonly mimeType: ImageMimeType;
|
|
43
|
+
readonly width: number;
|
|
44
|
+
readonly height: number;
|
|
45
|
+
readonly bytes: number;
|
|
46
|
+
readonly resized: boolean;
|
|
47
|
+
readonly frames: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type ToolContent = AgentToolResult<unknown>["content"];
|
|
51
|
+
|
|
52
|
+
/** Never decode past this: a mislabelled video is rejected on its size alone. */
|
|
53
|
+
const MAX_SOURCE_BYTES = 25 * 1024 * 1024;
|
|
54
|
+
const MAX_EDGE = 2000;
|
|
55
|
+
const MAX_ENCODED_BYTES = 3_932_160;
|
|
56
|
+
|
|
57
|
+
/** Enough for every magic-byte signature, WebP's `RIFF….WEBP` included. */
|
|
58
|
+
const SNIFF_BYTES = 12;
|
|
59
|
+
|
|
60
|
+
const CACHE_PREFIX = "img-";
|
|
61
|
+
|
|
62
|
+
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
63
|
+
const JPEG = [0xff, 0xd8, 0xff];
|
|
64
|
+
const GIF = [0x47, 0x49, 0x46, 0x38];
|
|
65
|
+
const RIFF = [0x52, 0x49, 0x46, 0x46];
|
|
66
|
+
const WEBP = [0x57, 0x45, 0x42, 0x50];
|
|
67
|
+
const ZIP = [0x50, 0x4b, 0x03, 0x04];
|
|
68
|
+
const PDF = [0x25, 0x50, 0x44, 0x46, 0x2d];
|
|
69
|
+
const ANMF = [0x41, 0x4e, 0x4d, 0x46];
|
|
70
|
+
const ACTL = [0x61, 0x63, 0x54, 0x4c];
|
|
71
|
+
const IDAT = [0x49, 0x44, 0x41, 0x54];
|
|
72
|
+
|
|
73
|
+
const GIF_EXTENSION = 0x21;
|
|
74
|
+
const GIF_IMAGE_DESCRIPTOR = 0x2c;
|
|
75
|
+
|
|
76
|
+
function startsWith(
|
|
77
|
+
bytes: Uint8Array,
|
|
78
|
+
signature: readonly number[],
|
|
79
|
+
offset = 0
|
|
80
|
+
): boolean {
|
|
81
|
+
return signature.every((byte, index) => bytes[offset + index] === byte);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Magic bytes only: an extension is a claim, not evidence. */
|
|
85
|
+
function sniff(bytes: Uint8Array): ImageMimeType | null {
|
|
86
|
+
if (startsWith(bytes, PNG)) {
|
|
87
|
+
return "image/png";
|
|
88
|
+
}
|
|
89
|
+
if (startsWith(bytes, JPEG)) {
|
|
90
|
+
return "image/jpeg";
|
|
91
|
+
}
|
|
92
|
+
if (startsWith(bytes, GIF)) {
|
|
93
|
+
return "image/gif";
|
|
94
|
+
}
|
|
95
|
+
if (startsWith(bytes, RIFF) && startsWith(bytes, WEBP, 8)) {
|
|
96
|
+
return "image/webp";
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function view(bytes: Uint8Array): DataView {
|
|
102
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** RIFF chunks are padded to an even length, so an odd body carries one trailing byte. */
|
|
106
|
+
function webpFrames(bytes: Uint8Array): number {
|
|
107
|
+
const reader = view(bytes);
|
|
108
|
+
let count = 0;
|
|
109
|
+
let at = 12;
|
|
110
|
+
while (at + 8 <= bytes.byteLength) {
|
|
111
|
+
const size = reader.getUint32(at + 4, true);
|
|
112
|
+
if (startsWith(bytes, ANMF, at)) {
|
|
113
|
+
count += 1;
|
|
114
|
+
}
|
|
115
|
+
at += 8 + size + (size % 2);
|
|
116
|
+
}
|
|
117
|
+
return count;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `acTL` carries the count outright, and must appear before the first `IDAT`. */
|
|
121
|
+
function apngFrames(bytes: Uint8Array): number {
|
|
122
|
+
const reader = view(bytes);
|
|
123
|
+
let at = 8;
|
|
124
|
+
while (at + 12 <= bytes.byteLength) {
|
|
125
|
+
if (startsWith(bytes, ACTL, at + 4)) {
|
|
126
|
+
return reader.getUint32(at + 8);
|
|
127
|
+
}
|
|
128
|
+
if (startsWith(bytes, IDAT, at + 4)) {
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
at += 12 + reader.getUint32(at);
|
|
132
|
+
}
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function gifFrames(bytes: Uint8Array): number {
|
|
137
|
+
let at = 13 + colourTableBytes(bytes[10] ?? 0);
|
|
138
|
+
let count = 0;
|
|
139
|
+
while (at < bytes.byteLength) {
|
|
140
|
+
if (bytes[at] === GIF_IMAGE_DESCRIPTOR) {
|
|
141
|
+
count += 1;
|
|
142
|
+
at = subBlocksEnd(bytes, at + 11 + colourTableBytes(bytes[at + 9] ?? 0));
|
|
143
|
+
} else if (bytes[at] === GIF_EXTENSION) {
|
|
144
|
+
at = subBlocksEnd(bytes, at + 2);
|
|
145
|
+
} else {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return count;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function colourTableBytes(packed: number): number {
|
|
153
|
+
return (packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function subBlocksEnd(bytes: Uint8Array, from: number): number {
|
|
157
|
+
let at = from;
|
|
158
|
+
while (at < bytes.byteLength) {
|
|
159
|
+
const size = bytes[at] ?? 0;
|
|
160
|
+
if (size === 0) {
|
|
161
|
+
return at + 1;
|
|
162
|
+
}
|
|
163
|
+
at += 1 + size;
|
|
164
|
+
}
|
|
165
|
+
return bytes.byteLength;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const FRAME_READERS: Partial<
|
|
169
|
+
Record<ImageMimeType, (bytes: Uint8Array) => number>
|
|
170
|
+
> = {
|
|
171
|
+
"image/webp": webpFrames,
|
|
172
|
+
"image/png": apngFrames,
|
|
173
|
+
"image/gif": gifFrames,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Headers only: the count has to be read before the resize, which keeps frame 1 alone. */
|
|
177
|
+
function frames(bytes: Uint8Array, mimeType: ImageMimeType): number {
|
|
178
|
+
return Math.max(1, FRAME_READERS[mimeType]?.(bytes) ?? 1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** An unknown model is not a blind one: only a model that names its inputs can rule the picture out. */
|
|
182
|
+
function canSee(model: VisionModel | undefined): boolean {
|
|
183
|
+
return model === undefined || model.input.includes("image");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What a model without eyes is told in place of the picture it cannot be sent. */
|
|
187
|
+
function noVisionNote(tool: string, subject: string): string {
|
|
188
|
+
return `[${tool} tool: ${subject}; the current model has no vision input.]`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A name that claims a picture — a claim the bytes still have to back. */
|
|
192
|
+
function looksLikeImageName(path: string): boolean {
|
|
193
|
+
return ImageMime.namesExtension(extname(path).slice(1).toLowerCase());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What the spill cache holds the picture under; the resize picks the extension, never the source path. */
|
|
197
|
+
function cacheName(sha256: string, mimeType: ImageMimeType): string {
|
|
198
|
+
return `${CACHE_PREFIX}${sha256}.${ImageMime.extensionOf(mimeType)}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Refuse on the stat alone, so a mislabelled video is never pulled into memory. */
|
|
202
|
+
function assertWithinSourceCap(byteLength: number, path: string): void {
|
|
203
|
+
if (byteLength > MAX_SOURCE_BYTES) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Image is ${Format.bytes(byteLength)}, over the ${Format.bytes(MAX_SOURCE_BYTES)} read cap. Shrink it first: magick ${path} -resize ${MAX_EDGE}x${MAX_EDGE} /tmp/small.png`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function normalise(
|
|
211
|
+
bytes: Uint8Array,
|
|
212
|
+
path: string
|
|
213
|
+
): Promise<NormalisedImage> {
|
|
214
|
+
assertWithinSourceCap(bytes.byteLength, path);
|
|
215
|
+
|
|
216
|
+
const sniffed = sniff(bytes);
|
|
217
|
+
if (sniffed === null) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`File has an image extension but its content is not a valid PNG/JPEG/GIF/WebP. Detected: ${describe(bytes)}. This usually means a download saved an error page instead of the image. Use bash: file ${path}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const resized = await resizeImage(bytes, sniffed, {
|
|
224
|
+
maxWidth: MAX_EDGE,
|
|
225
|
+
maxHeight: MAX_EDGE,
|
|
226
|
+
maxBytes: MAX_ENCODED_BYTES,
|
|
227
|
+
});
|
|
228
|
+
if (resized === null) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Could not shrink ${path} (${Format.bytes(bytes.byteLength)}) under the ${Format.bytes(MAX_ENCODED_BYTES)} provider limit. Re-save it smaller: magick ${path} -resize ${MAX_EDGE}x${MAX_EDGE} -quality 80 /tmp/small.jpg`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// An unresized encode is the input re-spelled: decoding it back would copy the bytes twice.
|
|
235
|
+
const data = resized.wasResized ? Buffer.from(resized.data, "base64") : bytes;
|
|
236
|
+
const mimeType = ImageMime.isSupported(resized.mimeType)
|
|
237
|
+
? resized.mimeType
|
|
238
|
+
: sniffed;
|
|
239
|
+
const sha256 = Bun.SHA256.hash(data, "hex");
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
base64: resized.data,
|
|
243
|
+
mimeType,
|
|
244
|
+
width: resized.width,
|
|
245
|
+
height: resized.height,
|
|
246
|
+
bytes: data.byteLength,
|
|
247
|
+
originalWidth: resized.originalWidth,
|
|
248
|
+
originalHeight: resized.originalHeight,
|
|
249
|
+
originalMimeType: sniffed,
|
|
250
|
+
resized:
|
|
251
|
+
resized.width !== resized.originalWidth ||
|
|
252
|
+
resized.height !== resized.originalHeight,
|
|
253
|
+
frames: frames(bytes, sniffed),
|
|
254
|
+
sha256,
|
|
255
|
+
cachePath: await SpillCache.writeNamed(cacheName(sha256, mimeType), data),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The record of the picture a tool hands its view, off the picture itself. */
|
|
260
|
+
function detailsOf(image: NormalisedImage): ImageDetails {
|
|
261
|
+
return {
|
|
262
|
+
sha256: image.sha256,
|
|
263
|
+
mimeType: image.mimeType,
|
|
264
|
+
width: image.width,
|
|
265
|
+
height: image.height,
|
|
266
|
+
bytes: image.bytes,
|
|
267
|
+
resized: image.resized,
|
|
268
|
+
frames: image.frames,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Text first, the way pi's own read orders it. */
|
|
273
|
+
function contentOf(image: NormalisedImage, note?: string): ToolContent {
|
|
274
|
+
const picture = {
|
|
275
|
+
type: "image" as const,
|
|
276
|
+
data: image.base64,
|
|
277
|
+
mimeType: image.mimeType,
|
|
278
|
+
};
|
|
279
|
+
return note === undefined
|
|
280
|
+
? [picture]
|
|
281
|
+
: [{ type: "text", text: note }, picture];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** How the model maps coordinates it reads off the picture back onto the file. */
|
|
285
|
+
function resizeNote(image: NormalisedImage): string | undefined {
|
|
286
|
+
if (!image.resized) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const scale = (image.originalWidth / image.width).toFixed(2);
|
|
290
|
+
return `image resized from ${image.originalWidth}x${image.originalHeight} to ${image.width}x${image.height}; multiply coordinates by ${scale} to map to the original.`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* What the picture leaves out, which a still under every cap says nothing about:
|
|
295
|
+
* providers see one frame, so an unmentioned animation reads as an empty screen.
|
|
296
|
+
*/
|
|
297
|
+
function animationNote(image: NormalisedImage): string | undefined {
|
|
298
|
+
if (image.frames < 2) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
return `animated ${ImageMime.extensionOf(image.originalMimeType)}: ${image.frames} frames, ${image.originalWidth}x${image.originalHeight}; frame 1 shown.`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Everything true of the picture that is not in the picture. */
|
|
305
|
+
function noteOf(image: NormalisedImage): string | undefined {
|
|
306
|
+
const notes = [resizeNote(image), animationNote(image)].filter(
|
|
307
|
+
(note) => note !== undefined
|
|
308
|
+
);
|
|
309
|
+
return notes.length === 0 ? undefined : notes.join(" ");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** What the bytes are instead, in the words a person would use to go find them. */
|
|
313
|
+
function describe(bytes: Uint8Array): string {
|
|
314
|
+
if (bytes.length === 0) {
|
|
315
|
+
return "an empty file";
|
|
316
|
+
}
|
|
317
|
+
if (startsWith(bytes, PDF)) {
|
|
318
|
+
return "PDF document";
|
|
319
|
+
}
|
|
320
|
+
if (startsWith(bytes, ZIP)) {
|
|
321
|
+
return "ZIP archive (.pptx/.docx/.xlsx are ZIPs)";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const head = new TextDecoder().decode(bytes.subarray(0, 512)).trimStart();
|
|
325
|
+
if (/^<(?:!doctype html|html|head|body)\b/i.test(head)) {
|
|
326
|
+
return "HTML document";
|
|
327
|
+
}
|
|
328
|
+
if (!Lines.isBinaryBytes(bytes)) {
|
|
329
|
+
return "JSON or text";
|
|
330
|
+
}
|
|
331
|
+
return `unrecognized bytes (hex: ${hex(bytes)})`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function hex(bytes: Uint8Array): string {
|
|
335
|
+
return Array.from(bytes.subarray(0, 8), (byte) =>
|
|
336
|
+
byte.toString(16).padStart(2, "0")
|
|
337
|
+
).join(" ");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export const Images = {
|
|
341
|
+
MAX_SOURCE_BYTES,
|
|
342
|
+
SNIFF_BYTES,
|
|
343
|
+
CACHE_PREFIX,
|
|
344
|
+
sniff,
|
|
345
|
+
frames,
|
|
346
|
+
isSupported: ImageMime.isSupported,
|
|
347
|
+
canSee,
|
|
348
|
+
noVisionNote,
|
|
349
|
+
extensionOf: ImageMime.extensionOf,
|
|
350
|
+
looksLikeImageName,
|
|
351
|
+
assertWithinSourceCap,
|
|
352
|
+
normalise,
|
|
353
|
+
detailsOf,
|
|
354
|
+
contentOf,
|
|
355
|
+
noteOf,
|
|
356
|
+
cacheName,
|
|
357
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function asRecord(
|
|
2
|
+
value: unknown
|
|
3
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
4
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return value as Readonly<Record<string, unknown>>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function tryParseJson(text: string): unknown | undefined {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(text);
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Json = { asRecord, tryParseJson };
|