@better-fs-tools/read 0.0.0-tegami-trusted-publish-setup → 0.2.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +807 -6
  3. package/dist/authorize/ask-user.d.ts +7 -0
  4. package/dist/authorize/ask-user.js +25 -0
  5. package/dist/authorize/authorizers.d.ts +7 -0
  6. package/dist/authorize/authorizers.js +34 -0
  7. package/dist/authorize/deny-paths.d.ts +7 -0
  8. package/dist/authorize/deny-paths.js +25 -0
  9. package/dist/authorize/glob.d.ts +7 -0
  10. package/dist/authorize/glob.js +46 -0
  11. package/dist/authorize/index.d.ts +5 -0
  12. package/dist/authorize/index.js +5 -0
  13. package/dist/authorize/shared.d.ts +9 -0
  14. package/dist/authorize/shared.js +12 -0
  15. package/dist/authorize/size-ceiling.d.ts +10 -0
  16. package/dist/authorize/size-ceiling.js +34 -0
  17. package/dist/budget/chars-per-token.d.ts +9 -0
  18. package/dist/budget/chars-per-token.js +15 -0
  19. package/dist/budget/index.d.ts +2 -0
  20. package/dist/budget/index.js +1 -0
  21. package/dist/classifiers/binary.d.ts +5 -0
  22. package/dist/classifiers/binary.js +13 -0
  23. package/dist/classifiers/extension.d.ts +17 -0
  24. package/dist/classifiers/extension.js +45 -0
  25. package/dist/classifiers/image.d.ts +5 -0
  26. package/dist/classifiers/image.js +22 -0
  27. package/dist/classifiers/index.d.ts +16 -0
  28. package/dist/classifiers/index.js +31 -0
  29. package/dist/classifiers/notebook.d.ts +5 -0
  30. package/dist/classifiers/notebook.js +14 -0
  31. package/dist/classifiers/office.d.ts +5 -0
  32. package/dist/classifiers/office.js +30 -0
  33. package/dist/classifiers/pdf.d.ts +4 -0
  34. package/dist/classifiers/pdf.js +8 -0
  35. package/dist/classifiers/shared.d.ts +17 -0
  36. package/dist/classifiers/shared.js +57 -0
  37. package/dist/classifiers/utf8.d.ts +8 -0
  38. package/dist/classifiers/utf8.js +61 -0
  39. package/dist/contract/base.d.ts +126 -0
  40. package/dist/contract/base.js +0 -0
  41. package/dist/contract/classify.d.ts +50 -0
  42. package/dist/contract/classify.js +0 -0
  43. package/dist/contract/context.d.ts +13 -0
  44. package/dist/contract/context.js +0 -0
  45. package/dist/contract/deps.d.ts +60 -0
  46. package/dist/contract/deps.js +0 -0
  47. package/dist/contract/digest.d.ts +13 -0
  48. package/dist/contract/digest.js +0 -0
  49. package/dist/contract/extensions.d.ts +115 -0
  50. package/dist/contract/extensions.js +0 -0
  51. package/dist/contract/format.d.ts +16 -0
  52. package/dist/contract/format.js +0 -0
  53. package/dist/contract/input.d.ts +16 -0
  54. package/dist/contract/input.js +0 -0
  55. package/dist/contract/json.d.ts +6 -0
  56. package/dist/contract/json.js +0 -0
  57. package/dist/contract/limits.d.ts +20 -0
  58. package/dist/contract/limits.js +0 -0
  59. package/dist/contract/messages.d.ts +110 -0
  60. package/dist/contract/messages.js +0 -0
  61. package/dist/contract/result.d.ts +153 -0
  62. package/dist/contract/result.js +0 -0
  63. package/dist/contract/state.d.ts +36 -0
  64. package/dist/contract/state.js +0 -0
  65. package/dist/converters/directory-listing.d.ts +13 -0
  66. package/dist/converters/directory-listing.js +63 -0
  67. package/dist/converters/image.d.ts +7 -0
  68. package/dist/converters/image.js +25 -0
  69. package/dist/converters/index.d.ts +8 -0
  70. package/dist/converters/index.js +4 -0
  71. package/dist/converters/notebook.d.ts +12 -0
  72. package/dist/converters/notebook.js +106 -0
  73. package/dist/converters/shared.d.ts +5 -0
  74. package/dist/converters/shared.js +18 -0
  75. package/dist/converters/text.d.ts +12 -0
  76. package/dist/converters/text.js +24 -0
  77. package/dist/core/authorize.d.ts +8 -0
  78. package/dist/core/authorize.js +76 -0
  79. package/dist/core/budget.d.ts +7 -0
  80. package/dist/core/budget.js +23 -0
  81. package/dist/core/call-scope.d.ts +36 -0
  82. package/dist/core/call-scope.js +112 -0
  83. package/dist/core/classify.d.ts +9 -0
  84. package/dist/core/classify.js +27 -0
  85. package/dist/core/convert-source.d.ts +29 -0
  86. package/dist/core/convert-source.js +85 -0
  87. package/dist/core/convert.d.ts +23 -0
  88. package/dist/core/convert.js +132 -0
  89. package/dist/core/converted.d.ts +18 -0
  90. package/dist/core/converted.js +111 -0
  91. package/dist/core/create-read-tool.d.ts +7 -0
  92. package/dist/core/create-read-tool.js +18 -0
  93. package/dist/core/cursor.d.ts +14 -0
  94. package/dist/core/cursor.js +93 -0
  95. package/dist/core/deps.d.ts +6 -0
  96. package/dist/core/deps.js +121 -0
  97. package/dist/core/directory.d.ts +16 -0
  98. package/dist/core/directory.js +113 -0
  99. package/dist/core/extension-error.d.ts +9 -0
  100. package/dist/core/extension-error.js +18 -0
  101. package/dist/core/format.d.ts +8 -0
  102. package/dist/core/format.js +29 -0
  103. package/dist/core/hooks.d.ts +3 -0
  104. package/dist/core/hooks.js +140 -0
  105. package/dist/core/input.d.ts +10 -0
  106. package/dist/core/input.js +45 -0
  107. package/dist/core/limits.d.ts +9 -0
  108. package/dist/core/limits.js +43 -0
  109. package/dist/core/messages.d.ts +8 -0
  110. package/dist/core/messages.js +78 -0
  111. package/dist/core/notes.d.ts +14 -0
  112. package/dist/core/notes.js +94 -0
  113. package/dist/core/observation.d.ts +11 -0
  114. package/dist/core/observation.js +51 -0
  115. package/dist/core/open.d.ts +16 -0
  116. package/dist/core/open.js +33 -0
  117. package/dist/core/outcomes.d.ts +27 -0
  118. package/dist/core/outcomes.js +172 -0
  119. package/dist/core/pipeline.d.ts +4 -0
  120. package/dist/core/pipeline.js +175 -0
  121. package/dist/core/record.d.ts +3 -0
  122. package/dist/core/record.js +37 -0
  123. package/dist/core/resolve.d.ts +15 -0
  124. package/dist/core/resolve.js +43 -0
  125. package/dist/core/same.d.ts +2 -0
  126. package/dist/core/same.js +22 -0
  127. package/dist/core/sample.d.ts +6 -0
  128. package/dist/core/sample.js +24 -0
  129. package/dist/core/scan.d.ts +26 -0
  130. package/dist/core/scan.js +95 -0
  131. package/dist/core/scanner.d.ts +29 -0
  132. package/dist/core/scanner.js +124 -0
  133. package/dist/core/suggest.d.ts +6 -0
  134. package/dist/core/suggest.js +41 -0
  135. package/dist/core/text-outcome.d.ts +19 -0
  136. package/dist/core/text-outcome.js +72 -0
  137. package/dist/core/verify.d.ts +7 -0
  138. package/dist/core/verify.js +15 -0
  139. package/dist/digest/sha256.d.ts +7 -0
  140. package/dist/digest/sha256.js +131 -0
  141. package/dist/formats/deep-agents.d.ts +7 -0
  142. package/dist/formats/deep-agents.js +23 -0
  143. package/dist/formats/hashline.d.ts +6 -0
  144. package/dist/formats/hashline.js +16 -0
  145. package/dist/formats/hermes.d.ts +9 -0
  146. package/dist/formats/hermes.js +62 -0
  147. package/dist/formats/index.d.ts +4 -0
  148. package/dist/formats/index.js +4 -0
  149. package/dist/formats/opencode.d.ts +7 -0
  150. package/dist/formats/opencode.js +35 -0
  151. package/dist/formats/shared.d.ts +4 -0
  152. package/dist/formats/shared.js +9 -0
  153. package/dist/formatters/eof.d.ts +7 -0
  154. package/dist/formatters/eof.js +20 -0
  155. package/dist/formatters/file-hash.d.ts +7 -0
  156. package/dist/formatters/file-hash.js +13 -0
  157. package/dist/formatters/hashline.d.ts +11 -0
  158. package/dist/formatters/hashline.js +18 -0
  159. package/dist/formatters/index.d.ts +10 -0
  160. package/dist/formatters/index.js +7 -0
  161. package/dist/formatters/json.d.ts +17 -0
  162. package/dist/formatters/json.js +36 -0
  163. package/dist/formatters/layout.d.ts +15 -0
  164. package/dist/formatters/layout.js +33 -0
  165. package/dist/formatters/line-number.d.ts +11 -0
  166. package/dist/formatters/line-number.js +23 -0
  167. package/dist/formatters/plain.d.ts +7 -0
  168. package/dist/formatters/plain.js +14 -0
  169. package/dist/hooks/index.d.ts +4 -0
  170. package/dist/hooks/index.js +2 -0
  171. package/dist/hooks/redact.d.ts +14 -0
  172. package/dist/hooks/redact.js +53 -0
  173. package/dist/hooks/repeat-read-guard.d.ts +13 -0
  174. package/dist/hooks/repeat-read-guard.js +49 -0
  175. package/dist/index.d.ts +35 -0
  176. package/dist/index.js +15 -0
  177. package/dist/resolve/expand-home.d.ts +5 -0
  178. package/dist/resolve/expand-home.js +17 -0
  179. package/dist/resolve/index.d.ts +5 -0
  180. package/dist/resolve/index.js +5 -0
  181. package/dist/resolve/path-resolvers.d.ts +7 -0
  182. package/dist/resolve/path-resolvers.js +38 -0
  183. package/dist/resolve/reanchor-leading-slash.d.ts +9 -0
  184. package/dist/resolve/reanchor-leading-slash.js +23 -0
  185. package/dist/resolve/strip-prefixes.d.ts +6 -0
  186. package/dist/resolve/strip-prefixes.js +30 -0
  187. package/dist/resolve/unicode-repair.d.ts +9 -0
  188. package/dist/resolve/unicode-repair.js +38 -0
  189. package/dist/signature/contract.d.ts +18 -0
  190. package/dist/signature/contract.js +0 -0
  191. package/dist/signature/index.d.ts +4 -0
  192. package/dist/signature/index.js +3 -0
  193. package/dist/signature/line-range.d.ts +7 -0
  194. package/dist/signature/line-range.js +59 -0
  195. package/dist/signature/messages.d.ts +4 -0
  196. package/dist/signature/messages.js +9 -0
  197. package/dist/signature/offset-limit.d.ts +13 -0
  198. package/dist/signature/offset-limit.js +59 -0
  199. package/dist/signature/schema.d.ts +10 -0
  200. package/dist/signature/schema.js +80 -0
  201. package/dist/state/index.d.ts +15 -0
  202. package/dist/state/index.js +77 -0
  203. package/dist/suggest/canonicalize.d.ts +6 -0
  204. package/dist/suggest/canonicalize.js +12 -0
  205. package/dist/suggest/default-suggest.d.ts +3 -0
  206. package/dist/suggest/default-suggest.js +12 -0
  207. package/dist/suggest/index.d.ts +3 -0
  208. package/dist/suggest/index.js +3 -0
  209. package/dist/suggest/suggest-file-names.d.ts +2 -0
  210. package/dist/suggest/suggest-file-names.js +46 -0
  211. package/docs/architecture.md +250 -0
  212. package/docs/result-schema.md +222 -0
  213. package/package.json +51 -2
@@ -0,0 +1,93 @@
1
+ const INTERNAL_CHUNK_BYTES = 64 * 1_024;
2
+ export class AbortReadError extends Error {
3
+ }
4
+ export class ByteCursor {
5
+ iterator;
6
+ chunk = null;
7
+ pushed = null;
8
+ offset = 0;
9
+ constructor(source) {
10
+ this.iterator = source[Symbol.asyncIterator]();
11
+ }
12
+ unshift(piece) {
13
+ if (this.pushed !== null)
14
+ throw new Error("Only one byte piece can be pushed back");
15
+ this.pushed = piece;
16
+ }
17
+ async next(signal) {
18
+ if (this.pushed !== null) {
19
+ const piece = this.pushed;
20
+ this.pushed = null;
21
+ return { done: false, value: piece };
22
+ }
23
+ while (this.chunk === null || this.offset >= this.chunk.byteLength) {
24
+ this.chunk = null;
25
+ this.offset = 0;
26
+ const item = await this.pull(signal);
27
+ assertIteratorResult(item);
28
+ if (item.done)
29
+ return { done: true, value: undefined };
30
+ assertChunk(item.value);
31
+ if (item.value.byteLength === 0)
32
+ continue;
33
+ this.chunk = item.value;
34
+ }
35
+ const end = Math.min(this.offset + INTERNAL_CHUNK_BYTES, this.chunk.byteLength);
36
+ const piece = this.chunk.subarray(this.offset, end);
37
+ this.offset = end;
38
+ if (this.offset >= this.chunk.byteLength) {
39
+ this.chunk = null;
40
+ this.offset = 0;
41
+ }
42
+ return { done: false, value: piece };
43
+ }
44
+ async close() {
45
+ const iterator = this.iterator;
46
+ this.iterator = null;
47
+ this.chunk = null;
48
+ this.pushed = null;
49
+ try {
50
+ await iterator?.return?.(undefined);
51
+ }
52
+ catch {
53
+ // An adapter that fails to unwind is not allowed to fail the read.
54
+ }
55
+ }
56
+ async pull(signal) {
57
+ const iterator = this.iterator;
58
+ if (iterator === null)
59
+ return { done: true, value: undefined };
60
+ return raceAbort(() => iterator.next(), signal);
61
+ }
62
+ }
63
+ export async function raceAbort(start, signal) {
64
+ if (!signal)
65
+ return start();
66
+ if (signal.aborted)
67
+ throw new AbortReadError("aborted");
68
+ let removeListener;
69
+ const aborted = new Promise((_resolve, reject) => {
70
+ const onAbort = () => reject(new AbortReadError("aborted"));
71
+ signal.addEventListener("abort", onAbort, { once: true });
72
+ removeListener = () => signal.removeEventListener("abort", onAbort);
73
+ });
74
+ try {
75
+ return await Promise.race([(async () => start())(), aborted]);
76
+ }
77
+ finally {
78
+ removeListener?.();
79
+ }
80
+ }
81
+ function assertChunk(chunk) {
82
+ if (!(chunk instanceof Uint8Array)) {
83
+ throw new TypeError("Byte source yielded a non-Uint8Array chunk");
84
+ }
85
+ }
86
+ function assertIteratorResult(item) {
87
+ if (item === null ||
88
+ typeof item !== "object" ||
89
+ !("done" in item) ||
90
+ typeof item.done !== "boolean") {
91
+ throw new TypeError("Byte source returned an invalid iterator result");
92
+ }
93
+ }
@@ -0,0 +1,6 @@
1
+ import type { FileSystem } from "@better-fs-tools/fs";
2
+ import type { ReadDependencies, ReadToolDeps } from "../contract/deps.ts";
3
+ import type { ReadStateStore } from "../contract/state.ts";
4
+ export declare function resolveDependencies<THost>(deps: ReadToolDeps<THost>): ReadDependencies<THost>;
5
+ export declare function isFileSystem(value: unknown): value is FileSystem;
6
+ export declare function isStateStore(value: unknown): value is ReadStateStore;
@@ -0,0 +1,121 @@
1
+ import { defaultClassifiers } from "../classifiers/index.js";
2
+ import { lineNumberFormatter } from "../formatters/index.js";
3
+ import { defaultSuggest } from "../suggest/index.js";
4
+ import { isRecord } from "./input.js";
5
+ import { resolveReadLimits } from "./limits.js";
6
+ import { resolveReadMessages } from "./messages.js";
7
+ const KNOWN_DEPENDENCY_KEYS = new Set([
8
+ "fs",
9
+ "limits",
10
+ "messages",
11
+ "classifiers",
12
+ "resolve",
13
+ "suggest",
14
+ "authorize",
15
+ "converters",
16
+ "budget",
17
+ "hooks",
18
+ "state",
19
+ "digest",
20
+ "clock",
21
+ "formatter",
22
+ ]);
23
+ export function resolveDependencies(deps) {
24
+ if (!isRecord(deps))
25
+ throw new TypeError("read tool dependencies must be an object");
26
+ for (const key of Object.keys(deps)) {
27
+ if (!KNOWN_DEPENDENCY_KEYS.has(key))
28
+ throw new TypeError(`Unknown read tool dependency: ${key}`);
29
+ }
30
+ const { fs } = deps;
31
+ if (typeof fs !== "function" && !isFileSystem(fs)) {
32
+ throw new TypeError("fs must be a FileSystem or a function that returns one");
33
+ }
34
+ const classifiers = deps.classifiers ?? defaultClassifiers();
35
+ if (!Array.isArray(classifiers) || classifiers.length === 0) {
36
+ throw new TypeError("classifiers must be a non-empty array");
37
+ }
38
+ const formatter = deps.formatter ?? lineNumberFormatter();
39
+ if (!isRecord(formatter) || typeof formatter.format !== "function") {
40
+ throw new TypeError("formatter must have a format function");
41
+ }
42
+ const resolve = deps.resolve ?? null;
43
+ if (resolve !== null && (!isRecord(resolve) || typeof resolve.resolve !== "function")) {
44
+ throw new TypeError("resolve must be a path resolver or null");
45
+ }
46
+ const suggest = deps.suggest === undefined ? defaultSuggest() : deps.suggest;
47
+ if (suggest !== null && typeof suggest !== "function") {
48
+ throw new TypeError("suggest must be a function or null");
49
+ }
50
+ const authorize = deps.authorize ?? null;
51
+ if (authorize !== null && (!isRecord(authorize) || typeof authorize.authorize !== "function")) {
52
+ throw new TypeError("authorize must be an authorizer or null");
53
+ }
54
+ const converters = deps.converters === undefined ? [] : deps.converters;
55
+ if (!Array.isArray(converters) || !converters.every(isConverter)) {
56
+ throw new TypeError("converters must be an array of file and directory converters");
57
+ }
58
+ const budget = deps.budget ?? null;
59
+ if (budget !== null && !isViewBudget(budget)) {
60
+ throw new TypeError("budget must be a view budget with an id, measure, and a positive max, or null");
61
+ }
62
+ const hooks = deps.hooks === undefined ? [] : deps.hooks;
63
+ if (!Array.isArray(hooks) || !hooks.every(isHook)) {
64
+ throw new TypeError("hooks must be an array of hooks with an id and afterRead");
65
+ }
66
+ const clock = deps.clock ?? (() => new Date());
67
+ if (typeof clock !== "function")
68
+ throw new TypeError("clock must be a function");
69
+ const state = deps.state ?? null;
70
+ if (state !== null && typeof state !== "function" && !isStateStore(state)) {
71
+ throw new TypeError("state must be a store, a function that returns one, or null");
72
+ }
73
+ const digest = deps.digest ?? null;
74
+ if (state !== null && digest === null) {
75
+ throw new TypeError("state needs a digest: records name the digest that made them");
76
+ }
77
+ return Object.freeze({
78
+ fs,
79
+ limits: resolveReadLimits(deps.limits),
80
+ messages: resolveReadMessages(deps.messages),
81
+ classifiers: Object.freeze([...classifiers]),
82
+ resolve,
83
+ suggest,
84
+ authorize,
85
+ converters: Object.freeze([...converters]),
86
+ budget,
87
+ hooks: Object.freeze([...hooks]),
88
+ state,
89
+ digest,
90
+ clock,
91
+ formatter,
92
+ });
93
+ }
94
+ export function isFileSystem(value) {
95
+ return isRecord(value) && typeof value.open === "function";
96
+ }
97
+ function isConverter(value) {
98
+ if (!isRecord(value) || typeof value.id !== "string" || typeof value.convert !== "function") {
99
+ return false;
100
+ }
101
+ if (value.target === "directory")
102
+ return true;
103
+ return value.target === "file" && typeof value.accepts === "function";
104
+ }
105
+ function isViewBudget(value) {
106
+ return (isRecord(value) &&
107
+ typeof value.id === "string" &&
108
+ typeof value.measure === "function" &&
109
+ typeof value.max === "number" &&
110
+ Number.isFinite(value.max) &&
111
+ value.max > 0);
112
+ }
113
+ function isHook(value) {
114
+ return isRecord(value) && typeof value.id === "string" && typeof value.afterRead === "function";
115
+ }
116
+ export function isStateStore(value) {
117
+ return (isRecord(value) &&
118
+ typeof value.get === "function" &&
119
+ typeof value.put === "function" &&
120
+ typeof value.delete === "function");
121
+ }
@@ -0,0 +1,16 @@
1
+ import type { FileSystem, NotAFileError } from "@better-fs-tools/fs";
2
+ import type { DirectoryConverter } from "../contract/extensions.ts";
3
+ import type { ReadRequest } from "../contract/input.ts";
4
+ import type { ReadReport } from "../contract/result.ts";
5
+ import type { CallScope } from "./call-scope.ts";
6
+ export declare function directoryConverter<THost>(scope: CallScope<THost>, fs: FileSystem): DirectoryConverter<THost> | null;
7
+ export interface ConvertDirectoryInput<THost> {
8
+ readonly fs: FileSystem;
9
+ readonly request: ReadRequest;
10
+ readonly openedPath: string;
11
+ readonly error: NotAFileError;
12
+ readonly resolvedFrom: string | null;
13
+ readonly converter: DirectoryConverter<THost>;
14
+ readonly scope: CallScope<THost>;
15
+ }
16
+ export declare function convertDirectory<THost>(input: ConvertDirectoryInput<THost>): Promise<ReadReport>;
@@ -0,0 +1,113 @@
1
+ import { checkConvertOutcome, firstMediaType, mediaBytes, refusedOutcome, scanConvertedText, tooLarge, } from "./converted.js";
2
+ import { AbortReadError, raceAbort } from "./cursor.js";
3
+ import { extensionId } from "./extension-error.js";
4
+ import { ReadStop, fromFileSystemError } from "./outcomes.js";
5
+ import { textOutcome } from "./text-outcome.js";
6
+ const DIRECTORY = Object.freeze({
7
+ kind: "directory",
8
+ classifier: "fs",
9
+ code: null,
10
+ mimeType: null,
11
+ confidence: "high",
12
+ reasons: Object.freeze(["directory"]),
13
+ });
14
+ export function directoryConverter(scope, fs) {
15
+ if (typeof fs.list !== "function")
16
+ return null;
17
+ for (const converter of scope.deps.converters) {
18
+ if (converter.target === "directory")
19
+ return converter;
20
+ }
21
+ return null;
22
+ }
23
+ export async function convertDirectory(input) {
24
+ const { fs, request, openedPath: path, error, converter, scope } = input;
25
+ const { limits, messages } = scope.deps;
26
+ // A virtual backend's EISDIR has no target, so the opened path stands in.
27
+ const dir = error.target?.resolvedPath ?? path;
28
+ const file = {
29
+ requestedPath: request.path,
30
+ resolvedPath: dir,
31
+ displayPath: error.target?.displayPath ?? path,
32
+ backend: fs.id,
33
+ size: null,
34
+ mtimeMs: null,
35
+ identity: null,
36
+ mimeType: null,
37
+ resolvedFrom: input.resolvedFrom,
38
+ version: null,
39
+ };
40
+ let listing = null;
41
+ const convertInput = Object.freeze({
42
+ path,
43
+ target: error.target,
44
+ async list() {
45
+ const listed = await scope.list("open", dir, file.displayPath);
46
+ listing ??= listed;
47
+ return listed;
48
+ },
49
+ });
50
+ const throwIfListingFailed = () => {
51
+ scope.checkAbort();
52
+ scope.throwHeld();
53
+ const listed = listing;
54
+ if (listed !== null && !listed.ok) {
55
+ throw new ReadStop(fromFileSystemError(messages, request, listed.error, scope.phase));
56
+ }
57
+ };
58
+ const failed = (thrown) => {
59
+ throwIfListingFailed();
60
+ return scope.extensionFailure("converters", extensionId(converter, thrown));
61
+ };
62
+ scope.enter("conversion");
63
+ let produced;
64
+ try {
65
+ produced = await raceAbort(() => converter.convert(convertInput, scope.hookContext()), scope.signal);
66
+ }
67
+ catch (thrown) {
68
+ throw failed(thrown);
69
+ }
70
+ throwIfListingFailed();
71
+ const outcome = checkConvertOutcome(produced);
72
+ if (outcome === null)
73
+ throw failed();
74
+ if (outcome.kind === "refuse") {
75
+ return refusedOutcome(request, file, DIRECTORY, outcome.code, outcome.note);
76
+ }
77
+ if (outcome.kind === "media") {
78
+ if (mediaBytes(outcome.parts) > limits.maxMediaBytes) {
79
+ return tooLarge(messages, request, file, DIRECTORY, "media", limits.maxMediaBytes);
80
+ }
81
+ return {
82
+ tool: "read",
83
+ status: "media",
84
+ request,
85
+ file,
86
+ classification: DIRECTORY,
87
+ conversion: { converter: converter.id, mimeType: firstMediaType(outcome.parts) },
88
+ parts: outcome.parts,
89
+ observation: null,
90
+ notes: outcome.notes ?? [],
91
+ };
92
+ }
93
+ let scan;
94
+ try {
95
+ scan = await scanConvertedText(outcome.text, request, file, scope);
96
+ }
97
+ catch (thrown) {
98
+ if (thrown instanceof ReadStop || thrown instanceof AbortReadError)
99
+ throw thrown;
100
+ throw failed(thrown);
101
+ }
102
+ return textOutcome({
103
+ deps: scope.deps,
104
+ fs: null,
105
+ request,
106
+ file,
107
+ classification: DIRECTORY,
108
+ conversion: { converter: converter.id, mimeType: outcome.mimeType },
109
+ scan,
110
+ contentId: null,
111
+ notes: outcome.notes ?? [],
112
+ });
113
+ }
@@ -0,0 +1,9 @@
1
+ /** Lets EXTENSION_FAILED name the failing step of a composite extension. */
2
+ export declare class ExtensionStepError extends Error {
3
+ readonly id: string;
4
+ constructor(id: string, cause: unknown);
5
+ }
6
+ export declare function stepFailure(step: {
7
+ readonly id: string;
8
+ }, error: unknown): ExtensionStepError;
9
+ export declare function extensionId(extension: unknown, error?: unknown): string | null;
@@ -0,0 +1,18 @@
1
+ /** Lets EXTENSION_FAILED name the failing step of a composite extension. */
2
+ export class ExtensionStepError extends Error {
3
+ id;
4
+ constructor(id, cause) {
5
+ super(`extension step ${id} failed`, { cause });
6
+ this.id = id;
7
+ }
8
+ }
9
+ export function stepFailure(step, error) {
10
+ return error instanceof ExtensionStepError ? error : new ExtensionStepError(step.id, error);
11
+ }
12
+ export function extensionId(extension, error) {
13
+ if (error instanceof ExtensionStepError)
14
+ return error.id;
15
+ if (extension === null || typeof extension !== "object" || !("id" in extension))
16
+ return null;
17
+ return typeof extension.id === "string" ? extension.id : null;
18
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReadContext } from "../contract/context.ts";
2
+ import type { ReadDependencies } from "../contract/deps.ts";
3
+ import type { ContentPart, ReadReport, ReadResult } from "../contract/result.ts";
4
+ export declare function formatResult<THost>(deps: ReadDependencies<THost>, call: ReadContext<THost>, outcome: ReadReport): ReadResult;
5
+ /** The text parts of any tool result's `content`, joined with newlines. */
6
+ export declare function textOf(result: {
7
+ readonly content: readonly ContentPart[];
8
+ }): string;
@@ -0,0 +1,29 @@
1
+ import { lineNumberFormatter } from "../formatters/index.js";
2
+ export function formatResult(deps, call, outcome) {
3
+ const ctx = {
4
+ digest: deps.digest,
5
+ limits: deps.limits,
6
+ mode: "model",
7
+ call,
8
+ };
9
+ let content = null;
10
+ try {
11
+ content = toContent(deps.formatter.format(outcome, ctx));
12
+ }
13
+ catch { }
14
+ if (content !== null)
15
+ return { ...outcome, content };
16
+ return { ...outcome, content: toContent(lineNumberFormatter().format(outcome, ctx)) ?? [] };
17
+ }
18
+ function toContent(output) {
19
+ if (typeof output === "string")
20
+ return [{ type: "text", text: output }];
21
+ return Array.isArray(output) ? [...output] : null;
22
+ }
23
+ /** The text parts of any tool result's `content`, joined with newlines. */
24
+ export function textOf(result) {
25
+ return result.content
26
+ .filter((part) => part.type === "text")
27
+ .map((part) => part.text)
28
+ .join("\n");
29
+ }
@@ -0,0 +1,3 @@
1
+ import type { ReadReport } from "../contract/result.ts";
2
+ import type { CallScope } from "./call-scope.ts";
3
+ export declare function runHooks<THost>(scope: CallScope<THost>, outcome: ReadReport): Promise<ReadReport>;
@@ -0,0 +1,140 @@
1
+ import { isContentPart } from "./converted.js";
2
+ import { raceAbort } from "./cursor.js";
3
+ import { extensionId } from "./extension-error.js";
4
+ import { isRecord } from "./input.js";
5
+ import { withEditedView } from "./observation.js";
6
+ import { isNote } from "./outcomes.js";
7
+ import { same } from "./same.js";
8
+ const HOOK_FROZEN_FIELDS = [
9
+ "tool",
10
+ "request",
11
+ "file",
12
+ "classification",
13
+ "conversion",
14
+ "truncation",
15
+ "continuation",
16
+ "totals",
17
+ "observation",
18
+ ];
19
+ const STATUSES = new Set(["ok", "media", "unsupported", "error"]);
20
+ export async function runHooks(scope, outcome) {
21
+ const { hooks } = scope.deps;
22
+ if (hooks.length === 0)
23
+ return outcome;
24
+ scope.enter("hooks");
25
+ const ctx = Object.freeze({
26
+ ...scope.hookContext(),
27
+ previous: await previousRecord(scope, outcome),
28
+ });
29
+ let current = outcome;
30
+ for (const hook of hooks) {
31
+ current = await runHook(scope, hook, current, ctx);
32
+ }
33
+ return current;
34
+ }
35
+ async function runHook(scope, hook, before, ctx) {
36
+ scope.checkAbort();
37
+ let produced;
38
+ try {
39
+ produced = await raceAbort(() => hook.afterRead(before, ctx), scope.signal);
40
+ }
41
+ catch (error) {
42
+ scope.checkAbort();
43
+ throw scope.extensionFailure("hooks", extensionId(hook, error));
44
+ }
45
+ scope.checkAbort();
46
+ if (!keepsRules(before, produced))
47
+ throw scope.extensionFailure("hooks", extensionId(hook));
48
+ if (!viewChanged(before, produced))
49
+ return produced;
50
+ return markEdited(scope, hook.id, produced);
51
+ }
52
+ function markEdited(scope, hookId, outcome) {
53
+ const { messages, digest } = scope.deps;
54
+ const note = {
55
+ code: "view-modified",
56
+ severity: "info",
57
+ message: messages.viewModified({ hook: hookId }),
58
+ data: { hook: hookId },
59
+ };
60
+ const notes = [...outcome.notes, note];
61
+ if (outcome.status !== "ok" && outcome.status !== "media")
62
+ return { ...outcome, notes };
63
+ if (outcome.observation === null || digest === null)
64
+ return { ...outcome, notes };
65
+ const view = outcome.status === "ok" ? viewText(outcome) : outcome.parts;
66
+ return { ...outcome, notes, observation: withEditedView(digest, outcome.observation, view) };
67
+ }
68
+ async function previousRecord(scope, outcome) {
69
+ if (outcome.file === null)
70
+ return null;
71
+ const store = scope.stateStore();
72
+ if (store === null)
73
+ return null;
74
+ let record;
75
+ try {
76
+ record = await store.get(outcome.file.resolvedPath);
77
+ }
78
+ catch {
79
+ // Session state is a cache: a failing store never fails the read.
80
+ return null;
81
+ }
82
+ return isRecord(record) && record.schema === 2 ? record : null;
83
+ }
84
+ function keepsRules(before, after) {
85
+ if (!isRecord(after) || !STATUSES.has(after.status))
86
+ return false;
87
+ if (!Array.isArray(after.notes) || !after.notes.every(isNote))
88
+ return false;
89
+ const refused = before.status === "error" || before.status === "unsupported";
90
+ if (refused && (after.status === "ok" || after.status === "media"))
91
+ return false;
92
+ if (!editableFieldsWellTyped(after))
93
+ return false;
94
+ for (const key of HOOK_FROZEN_FIELDS) {
95
+ const had = Object.hasOwn(before, key);
96
+ const has = Object.hasOwn(after, key);
97
+ if (had && has && !same(before[key], after[key]))
98
+ return false;
99
+ if (had !== has && before.status === after.status)
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ function editableFieldsWellTyped(after) {
105
+ switch (after.status) {
106
+ case "ok": {
107
+ const { view } = after;
108
+ return (isRecord(view) &&
109
+ Array.isArray(view.lines) &&
110
+ view.lines.every((line) => isRecord(line) &&
111
+ typeof line.number === "number" &&
112
+ typeof line.text === "string" &&
113
+ typeof line.clamped === "boolean"));
114
+ }
115
+ case "media":
116
+ return Array.isArray(after.parts) && after.parts.every(isContentPart);
117
+ case "unsupported":
118
+ return typeof after.code === "string" && after.code !== "";
119
+ default: {
120
+ const { error } = after;
121
+ return (isRecord(error) &&
122
+ typeof error.code === "string" &&
123
+ error.code !== "" &&
124
+ typeof error.phase === "string" &&
125
+ typeof error.message === "string");
126
+ }
127
+ }
128
+ }
129
+ function viewChanged(before, after) {
130
+ if (before.status === "ok" && after.status === "ok") {
131
+ const lines = (outcome) => outcome.view.lines.map((line) => [line.number, line.text]);
132
+ return !same(lines(before), lines(after));
133
+ }
134
+ if (before.status === "media" && after.status === "media")
135
+ return !same(before.parts, after.parts);
136
+ return false;
137
+ }
138
+ function viewText(outcome) {
139
+ return outcome.view.lines.map((line) => line.text).join("\n");
140
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReadRequest } from "../contract/input.ts";
2
+ import type { ReadLimits } from "../contract/limits.ts";
3
+ /**
4
+ * Validates raw read input. `offset` defaults to 1 and `limit` to `limits.maxLines`,
5
+ * and a larger `limit` is lowered to it. Throws TypeError on an unknown key, a
6
+ * blank path or one with NUL, or an offset or limit that is not a positive integer.
7
+ */
8
+ export declare function parseReadInput(input: unknown, limits: Readonly<ReadLimits>): ReadRequest;
9
+ export declare function clampedLimit(input: unknown, request: ReadRequest): number | null;
10
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,45 @@
1
+ const KEYS = new Set(["path", "offset", "limit"]);
2
+ /**
3
+ * Validates raw read input. `offset` defaults to 1 and `limit` to `limits.maxLines`,
4
+ * and a larger `limit` is lowered to it. Throws TypeError on an unknown key, a
5
+ * blank path or one with NUL, or an offset or limit that is not a positive integer.
6
+ */
7
+ export function parseReadInput(input, limits) {
8
+ if (!isRecord(input))
9
+ throw new TypeError("Read input must be an object");
10
+ for (const key of Object.keys(input)) {
11
+ if (!KEYS.has(key))
12
+ throw new TypeError(`Unknown read input key: ${key}`);
13
+ }
14
+ const path = validatePath(input.path);
15
+ const offset = input.offset === undefined ? 1 : validateInteger(input.offset, "offset");
16
+ const limit = input.limit === undefined
17
+ ? limits.maxLines
18
+ : Math.min(validateInteger(input.limit, "limit"), limits.maxLines);
19
+ return {
20
+ path,
21
+ offset,
22
+ limit,
23
+ ranged: input.offset !== undefined || input.limit !== undefined,
24
+ };
25
+ }
26
+ function validatePath(value) {
27
+ if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) {
28
+ throw new TypeError("path must be a non-empty string without NUL");
29
+ }
30
+ return value;
31
+ }
32
+ function validateInteger(value, name) {
33
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
34
+ throw new TypeError(`${name} must be a positive safe integer`);
35
+ }
36
+ return value;
37
+ }
38
+ export function clampedLimit(input, request) {
39
+ if (!isRecord(input) || typeof input.limit !== "number")
40
+ return null;
41
+ return input.limit > request.limit ? input.limit : null;
42
+ }
43
+ export function isRecord(value) {
44
+ return value !== null && typeof value === "object" && !Array.isArray(value);
45
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReadLimits } from "../contract/limits.ts";
2
+ /** The default read limits. They are not ceilings: a host may raise any of them. */
3
+ export declare const defaultReadLimits: Readonly<ReadLimits>;
4
+ /**
5
+ * Merges overrides over `defaultReadLimits`. Throws TypeError on an unknown key,
6
+ * a value that is not a positive safe integer, or a `sampleBytes` you set above
7
+ * `maxScanBytes`. A default `sampleBytes` above your `maxScanBytes` is lowered to it.
8
+ */
9
+ export declare function resolveReadLimits(overrides?: Partial<ReadLimits>): Readonly<ReadLimits>;
@@ -0,0 +1,43 @@
1
+ /** The default read limits. They are not ceilings: a host may raise any of them. */
2
+ export const defaultReadLimits = Object.freeze({
3
+ maxLines: 2_000,
4
+ maxViewBytes: 50 * 1_024,
5
+ maxCharsPerLine: 2_000,
6
+ maxScanBytes: 64 * 1_024 * 1_024,
7
+ sampleBytes: 8_192,
8
+ maxDirectoryEntries: 200,
9
+ maxSuggestions: 5,
10
+ maxConvertBytes: 64 * 1_024 * 1_024,
11
+ maxMediaBytes: 5 * 1_024 * 1_024,
12
+ });
13
+ const LIMIT_KEYS = Object.keys(defaultReadLimits);
14
+ /**
15
+ * Merges overrides over `defaultReadLimits`. Throws TypeError on an unknown key,
16
+ * a value that is not a positive safe integer, or a `sampleBytes` you set above
17
+ * `maxScanBytes`. A default `sampleBytes` above your `maxScanBytes` is lowered to it.
18
+ */
19
+ export function resolveReadLimits(overrides = {}) {
20
+ if (overrides === null || typeof overrides !== "object" || Array.isArray(overrides)) {
21
+ throw new TypeError("limits must be an object");
22
+ }
23
+ const unknown = Object.keys(overrides).find((key) => !Object.hasOwn(defaultReadLimits, key));
24
+ if (unknown !== undefined)
25
+ throw new TypeError(`Unknown read limit: ${unknown}`);
26
+ const resolved = { ...defaultReadLimits };
27
+ for (const key of LIMIT_KEYS) {
28
+ const value = overrides[key];
29
+ if (value === undefined)
30
+ continue;
31
+ if (!Number.isSafeInteger(value) || value <= 0) {
32
+ throw new TypeError(`${key} must be a positive safe integer`);
33
+ }
34
+ resolved[key] = value;
35
+ }
36
+ if (resolved.sampleBytes > resolved.maxScanBytes) {
37
+ if (overrides.sampleBytes !== undefined) {
38
+ throw new TypeError("sampleBytes must not be more than maxScanBytes");
39
+ }
40
+ resolved.sampleBytes = resolved.maxScanBytes;
41
+ }
42
+ return Object.freeze(resolved);
43
+ }