@better-fs-tools/read 0.0.0-stage → 0.1.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 +799 -2
  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 +15 -0
  28. package/dist/classifiers/index.js +32 -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 +120 -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 +133 -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 +43 -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 +81 -0
  111. package/dist/core/notes.d.ts +16 -0
  112. package/dist/core/notes.js +114 -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 +73 -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 +249 -0
  212. package/docs/result-schema.md +222 -0
  213. package/package.json +52 -4
@@ -0,0 +1,59 @@
1
+ import { DEFAULT_NAME, lineSchema, objectSchema, pathSchema, readLine, readObject, readPath, resolveNames, } from "./schema.js";
2
+ const PARAMS = ["path", "offset", "limit"];
3
+ export function defaultReadSignature(options = {}) {
4
+ if (options === null || typeof options !== "object") {
5
+ throw new TypeError("defaultReadSignature options must be an object");
6
+ }
7
+ const name = resolveNames(PARAMS, options.names);
8
+ const keys = PARAMS.map((param) => name[param]);
9
+ const describe = options.describe ?? {};
10
+ const schema = objectSchema([
11
+ [
12
+ name.path,
13
+ pathSchema(describe.path ??
14
+ "Path to read, relative to the working directory or absolute within an allowed root."),
15
+ ],
16
+ [
17
+ name.offset,
18
+ lineSchema(describe.offset ?? "One-based source line to start reading at. Defaults to 1."),
19
+ ],
20
+ [
21
+ name.limit,
22
+ lineSchema(describe.limit ??
23
+ "Maximum number of source lines to return, before the configured limits apply."),
24
+ ],
25
+ ], name.path);
26
+ return Object.freeze({
27
+ name: options.name ?? DEFAULT_NAME,
28
+ description: options.description ??
29
+ `Read a UTF-8 text file. \`${name.offset}\` is a one-based source line and ` +
30
+ `\`${name.limit}\` is a number of source lines. Output is bounded by line, byte, and ` +
31
+ "line-length limits. When the result reports truncation, follow the continuation it " +
32
+ `returns instead of guessing the next \`${name.offset}\`. Paths outside the allowed ` +
33
+ "roots, binary content, and special files are refused.",
34
+ schema,
35
+ toInput(input) {
36
+ const record = readObject(input, keys);
37
+ const path = readPath(record[name.path], name.path);
38
+ const offset = readLine(record[name.offset], name.offset);
39
+ const limit = readLine(record[name.limit], name.limit);
40
+ return canonical(path, offset, limit);
41
+ },
42
+ param: (canonical) => name[canonical],
43
+ fromInput(retry) {
44
+ const model = { [name.path]: retry.path };
45
+ if (retry.offset !== undefined)
46
+ model[name.offset] = retry.offset;
47
+ if (retry.limit !== undefined)
48
+ model[name.limit] = retry.limit;
49
+ return model;
50
+ },
51
+ });
52
+ }
53
+ export function canonical(path, offset, limit) {
54
+ return {
55
+ path,
56
+ ...(offset === undefined ? {} : { offset }),
57
+ ...(limit === undefined ? {} : { limit }),
58
+ };
59
+ }
@@ -0,0 +1,10 @@
1
+ import type { JsonObject } from "../contract/json.ts";
2
+ export declare const PATH_PATTERN = "^[^\\u0000]*[^\\s\\u0000][^\\u0000]*$";
3
+ export declare const DEFAULT_NAME = "read";
4
+ export declare function pathSchema(description: string): JsonObject;
5
+ export declare function lineSchema(description: string): JsonObject;
6
+ export declare function objectSchema(properties: readonly (readonly [string, JsonObject])[], requiredKey: string): JsonObject;
7
+ export declare function readObject(input: unknown, keys: readonly string[]): Record<string, unknown>;
8
+ export declare function readPath(value: unknown, name: string): string;
9
+ export declare function readLine(value: unknown, name: string): number | undefined;
10
+ export declare function resolveNames<TParam extends string>(params: readonly TParam[], names?: Partial<Record<TParam, string>>): Readonly<Record<TParam, string>>;
@@ -0,0 +1,80 @@
1
+ // No lookaround: strict grammar engines may refuse one.
2
+ export const PATH_PATTERN = "^[^\\u0000]*[^\\s\\u0000][^\\u0000]*$";
3
+ const PATH_REGEX = new RegExp(PATH_PATTERN, "u");
4
+ export const DEFAULT_NAME = "read";
5
+ export function pathSchema(description) {
6
+ return { type: "string", minLength: 1, pattern: PATH_PATTERN, description };
7
+ }
8
+ export function lineSchema(description) {
9
+ return { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description };
10
+ }
11
+ export function objectSchema(properties, requiredKey) {
12
+ return deepFreeze({
13
+ type: "object",
14
+ additionalProperties: false,
15
+ properties: Object.fromEntries(properties),
16
+ required: [requiredKey],
17
+ });
18
+ }
19
+ export function readObject(input, keys) {
20
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
21
+ throw new TypeError(`Read input must be an object with ${list(keys)}`);
22
+ }
23
+ const record = input;
24
+ for (const key of Object.keys(record)) {
25
+ if (!keys.includes(key)) {
26
+ throw new TypeError(`Unknown read input key: ${key}. Expected ${list(keys)}`);
27
+ }
28
+ }
29
+ return record;
30
+ }
31
+ export function readPath(value, name) {
32
+ if (value === undefined)
33
+ throw new TypeError(`${name} is required`);
34
+ if (typeof value !== "string" || !PATH_REGEX.test(value)) {
35
+ throw new TypeError(`${name} must be a non-blank string without NUL`);
36
+ }
37
+ return value;
38
+ }
39
+ export function readLine(value, name) {
40
+ if (value === undefined)
41
+ return undefined;
42
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
43
+ throw new TypeError(`${name} must be a positive integer`);
44
+ }
45
+ return value;
46
+ }
47
+ export function resolveNames(params, names = {}) {
48
+ if (names === null || typeof names !== "object" || Array.isArray(names)) {
49
+ throw new TypeError("names must be an object");
50
+ }
51
+ const resolved = {};
52
+ const seen = new Set();
53
+ for (const key of Object.keys(names)) {
54
+ if (!params.includes(key)) {
55
+ throw new TypeError(`Unknown parameter in names: ${key}. Expected ${list(params)}`);
56
+ }
57
+ }
58
+ for (const param of params) {
59
+ const name = names[param] ?? param;
60
+ if (typeof name !== "string" || name.trim().length === 0) {
61
+ throw new TypeError(`names.${param} must be a non-blank string`);
62
+ }
63
+ if (seen.has(name))
64
+ throw new TypeError(`Parameter name ${name} is used twice`);
65
+ seen.add(name);
66
+ resolved[param] = name;
67
+ }
68
+ return Object.freeze(resolved);
69
+ }
70
+ function list(keys) {
71
+ return keys.join(", ");
72
+ }
73
+ function deepFreeze(value) {
74
+ if (value !== null && typeof value === "object") {
75
+ for (const child of Object.values(value))
76
+ deepFreeze(child);
77
+ Object.freeze(value);
78
+ }
79
+ return value;
80
+ }
@@ -0,0 +1,15 @@
1
+ import type { Clock } from "../contract/digest.ts";
2
+ import type { ReadStateStore } from "../contract/state.ts";
3
+ export interface MemoryStoreOptions {
4
+ /** Default 1_000. */
5
+ readonly maxEntries?: number;
6
+ /** Default 30 minutes. */
7
+ readonly ttlMs?: number;
8
+ /** The same clock type as the tools' `clock` dependency. Default the time now. */
9
+ readonly clock?: Clock;
10
+ }
11
+ /**
12
+ * A process-local store with an entry cap and a TTL. Use one store per
13
+ * session, or namespace the keys of a shared store.
14
+ */
15
+ export declare function memoryStore(options?: MemoryStoreOptions): ReadStateStore;
@@ -0,0 +1,77 @@
1
+ const DEFAULT_MAX_ENTRIES = 1_000;
2
+ const DEFAULT_TTL_MS = 30 * 60 * 1_000;
3
+ /**
4
+ * A process-local store with an entry cap and a TTL. Use one store per
5
+ * session, or namespace the keys of a shared store.
6
+ */
7
+ export function memoryStore(options = {}) {
8
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
9
+ throw new TypeError("memory store options must be an object");
10
+ }
11
+ const maxEntries = positiveInteger(options.maxEntries ?? DEFAULT_MAX_ENTRIES, "maxEntries");
12
+ const ttlMs = positiveInteger(options.ttlMs ?? DEFAULT_TTL_MS, "ttlMs");
13
+ const clock = options.clock ?? (() => new Date());
14
+ if (typeof clock !== "function")
15
+ throw new TypeError("clock must be a function");
16
+ const entries = new Map();
17
+ const now = () => {
18
+ const value = clock();
19
+ const time = value instanceof Date ? value.getTime() : Number.NaN;
20
+ if (!Number.isFinite(time))
21
+ throw new TypeError("clock must return a valid Date");
22
+ return time;
23
+ };
24
+ const prune = (time) => {
25
+ for (const [key, entry] of entries) {
26
+ if (entry.expiresAt <= time)
27
+ entries.delete(key);
28
+ }
29
+ };
30
+ const markRecentlyUsed = (key, entry) => {
31
+ entries.delete(key);
32
+ entries.set(key, entry);
33
+ };
34
+ return Object.freeze({
35
+ async get(key) {
36
+ assertKey(key);
37
+ const time = now();
38
+ prune(time);
39
+ const entry = entries.get(key);
40
+ if (entry === undefined)
41
+ return null;
42
+ markRecentlyUsed(key, entry);
43
+ return entry.record;
44
+ },
45
+ async put(key, record) {
46
+ assertKey(key);
47
+ if (record === null || typeof record !== "object") {
48
+ throw new TypeError("record must be an object");
49
+ }
50
+ const time = now();
51
+ prune(time);
52
+ entries.delete(key);
53
+ entries.set(key, { record, expiresAt: time + ttlMs });
54
+ while (entries.size > maxEntries) {
55
+ const oldest = entries.keys().next().value;
56
+ if (oldest === undefined)
57
+ break;
58
+ entries.delete(oldest);
59
+ }
60
+ },
61
+ async delete(key) {
62
+ assertKey(key);
63
+ entries.delete(key);
64
+ },
65
+ });
66
+ }
67
+ function assertKey(key) {
68
+ if (typeof key !== "string" || key === "") {
69
+ throw new TypeError("store key must be a non-empty string");
70
+ }
71
+ }
72
+ function positiveInteger(value, name) {
73
+ if (!Number.isSafeInteger(value) || value <= 0) {
74
+ throw new TypeError(`${name} must be a positive safe integer`);
75
+ }
76
+ return value;
77
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Folds the differences that make a copied filename miss on a POSIX
3
+ * filesystem: NFC and NFD, narrow and non-breaking spaces, and typographic
4
+ * quotes.
5
+ */
6
+ export declare function canonicalizeFileName(value: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Folds the differences that make a copied filename miss on a POSIX
3
+ * filesystem: NFC and NFD, narrow and non-breaking spaces, and typographic
4
+ * quotes.
5
+ */
6
+ export function canonicalizeFileName(value) {
7
+ return value
8
+ .normalize("NFC")
9
+ .replace(/[  ]/gu, " ")
10
+ .replace(/[‘’]/gu, "'")
11
+ .replace(/[“”]/gu, '"');
12
+ }
@@ -0,0 +1,3 @@
1
+ import type { Suggest } from "../contract/extensions.ts";
2
+ /** suggestFileNames, with Unicode-equivalent names ranked first. */
3
+ export declare function defaultSuggest(): Suggest<unknown>;
@@ -0,0 +1,12 @@
1
+ import { canonicalizeFileName } from "./canonicalize.js";
2
+ import { suggestFileNames } from "./suggest-file-names.js";
3
+ /** suggestFileNames, with Unicode-equivalent names ranked first. */
4
+ export function defaultSuggest() {
5
+ return (ctx) => {
6
+ const names = ctx.entries.map((entry) => entry.name);
7
+ const canonical = canonicalizeFileName(ctx.name);
8
+ const equivalent = names.filter((name) => name !== ctx.name && canonicalizeFileName(name) === canonical);
9
+ const near = suggestFileNames(ctx.name, names, ctx.max);
10
+ return [...new Set([...equivalent, ...near])].slice(0, ctx.max);
11
+ };
12
+ }
@@ -0,0 +1,3 @@
1
+ export { canonicalizeFileName } from "./canonicalize.ts";
2
+ export { defaultSuggest } from "./default-suggest.ts";
3
+ export { suggestFileNames } from "./suggest-file-names.ts";
@@ -0,0 +1,3 @@
1
+ export { canonicalizeFileName } from "./canonicalize.js";
2
+ export { defaultSuggest } from "./default-suggest.js";
3
+ export { suggestFileNames } from "./suggest-file-names.js";
@@ -0,0 +1,2 @@
1
+ /** Up to `maximum` names close to `requested`: same stem, a shared prefix, or a small edit distance. */
2
+ export declare function suggestFileNames(requested: string, names: readonly string[], maximum: number): readonly string[];
@@ -0,0 +1,46 @@
1
+ /** Up to `maximum` names close to `requested`: same stem, a shared prefix, or a small edit distance. */
2
+ export function suggestFileNames(requested, names, maximum) {
3
+ const requestedLower = requested.toLowerCase();
4
+ const requestedStem = stem(requestedLower);
5
+ return names
6
+ .map((name) => ({ name, score: suggestionScore(requestedLower, requestedStem, name) }))
7
+ .filter(({ score }) => score < Number.POSITIVE_INFINITY)
8
+ .sort((left, right) => left.score - right.score || left.name.localeCompare(right.name))
9
+ .slice(0, maximum)
10
+ .map(({ name }) => name);
11
+ }
12
+ function suggestionScore(requested, requestedStem, candidate) {
13
+ const lower = candidate.toLowerCase();
14
+ if (lower === requested)
15
+ return 0;
16
+ if (stem(lower) === requestedStem)
17
+ return 10;
18
+ if (lower.startsWith(requested) || requested.startsWith(lower)) {
19
+ return 20 + Math.abs(lower.length - requested.length);
20
+ }
21
+ const distance = boundedDistance(requested, lower, 3);
22
+ return distance === null ? Number.POSITIVE_INFINITY : 30 + distance;
23
+ }
24
+ function stem(value) {
25
+ const index = value.lastIndexOf(".");
26
+ return index <= 0 ? value : value.slice(0, index);
27
+ }
28
+ function boundedDistance(left, right, maximum) {
29
+ if (Math.abs(left.length - right.length) > maximum)
30
+ return null;
31
+ let prior = Array.from({ length: right.length + 1 }, (_, index) => index);
32
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
33
+ const next = [leftIndex];
34
+ let rowMinimum = leftIndex;
35
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
36
+ const value = Math.min(prior[rightIndex] + 1, next[rightIndex - 1] + 1, prior[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1));
37
+ next.push(value);
38
+ rowMinimum = Math.min(rowMinimum, value);
39
+ }
40
+ if (rowMinimum > maximum)
41
+ return null;
42
+ prior = next;
43
+ }
44
+ const distance = prior[right.length];
45
+ return distance <= maximum ? distance : null;
46
+ }
@@ -0,0 +1,249 @@
1
+ # Architecture
2
+
3
+ This page explains how `@better-fs-tools/read` runs one read, and why it is built this way. [result-schema.md](result-schema.md) describes the result. The [README](../README.md) shows how to configure each step.
4
+
5
+ ## The pipeline
6
+
7
+ ```text
8
+ read(input, ctx?)
9
+ │ parseReadInput ........... strict canonical validation, limit clamp
10
+ │ fs(call) ................. once; a FileSystem or a factory result
11
+ ▼
12
+ ReadRequest { path, offset, limit, ranged }
13
+ │ resolve .................. one path, or not found; at most one ctx.list
14
+ ▼
15
+ fs.open(path) ................. roots, deny roots, realpath, type check
16
+ │ not found ──▶ suggest ... authorize(list) + one fs.list; NOT_FOUND with names
17
+ │ directory ──▶ directory converter: authorize(list) + one fs.list ─▶ text lines ─▶ scan
18
+ ▼
19
+ OpenFile (no content byte read yet)
20
+ │ authorize(read) .......... host policy on the open target
21
+ ▼
22
+ sample (limits.sampleBytes) ─▶ classifiers (sync)
23
+ │
24
+ ├─ a FileConverter accepts ─▶ convert(capped bytes)
25
+ │ ├─ text ─▶ scan
26
+ │ ├─ media ─▶ ReadMedia (size cap)
27
+ │ └─ refuse ─▶ ReadUnsupported
28
+ ├─ text ──────────────────────────────────▶ scan (lines, view bytes, budget, clamp, scan cap)
29
+ └─ unsupported ───────────────────────────▶ ReadUnsupported
30
+ ▼
31
+ size check + handle.verify() .. change detection, also after conversion
32
+ ▼
33
+ hooks.afterRead (in order) .... rule checks after each hook
34
+ ▼
35
+ record ........................ state(call), only when there is an observation
36
+ ▼
37
+ formatter.format(outcome, { mode: "model", call, ... }) ─▶ content
38
+ ▼
39
+ ReadResult
40
+ ```
41
+
42
+ | Stage | Phase | Dependency | What happens |
43
+ | ------------------- | -------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | Input | `input` | `limits` | `parseReadInput` accepts only `path`, `offset`, and `limit`. The path must be a non-blank string with no NUL. `offset` and `limit` must be positive safe integers. `limit` is clamped to `limits.maxLines`, and the tool adds a `clamped` info note. |
45
+ | Filesystem | `open` | `fs` | A `FileSystem`, or a factory of the call context. The factory runs once for each read. |
46
+ | Resolve | `resolve` | `resolve` | Changes the path string that goes into the one open, or reports not found. |
47
+ | Open | `open` | `fs`, `suggest` | One `fs.open()`. A miss lists the parent once for `suggest`. A directory goes to the first directory converter, if there is one. |
48
+ | Authorize | `authorize` | `authorize` | Host policy on the open file, before the core reads any content byte. See [What open() fetches before authorize](#what-open-fetches-before-authorize). |
49
+ | Sample and classify | `sampling` | `classifiers` | Reads `limits.sampleBytes`, then asks each classifier in order. The first opinion wins. |
50
+ | Convert | `conversion` | `converters` | The first file converter that accepts the classification reads a capped stream from byte 0. |
51
+ | Scan | `scan` | `limits`, `budget`, `digest` | Decodes UTF-8, splits lines, selects the view, clamps, counts, and hashes. |
52
+ | Verify | `verification` | `fs` | Checks the size and asks the open handle whether the file changed. |
53
+ | Hooks | `hooks` | `hooks`, `state` | Each hook gets the outcome and the previous record, and returns an outcome. |
54
+ | Record | none | `state`, `digest` | Stores the observation. A store failure does not fail the read. |
55
+ | Format | none | `formatter`, `messages` | Turns the outcome into content parts. |
56
+
57
+ The core owns line scanning, view selection, clamping, the byte limit, continuation arithmetic, change detection, and note assembly. That list is the reason the package exists. Everything else is a dependency.
58
+
59
+ ### What open() fetches before authorize
60
+
61
+ `@better-fs-tools/cloudflare-shell` and `@better-fs-tools/just-bash` buffer the whole file inside `open()`, so the bytes have left the backend before `authorize` runs. `@better-fs-tools/cloudflare-computer` does not buffer: its `open()` starts a `readFile` stream, so the request reaches the backend, but it reads no chunk before `authorize`. `nodeFileSystem` streams, and `memoryFileSystem` already holds the bytes. The core passes no byte on until `authorize` allows it. If a denied read must not reach the backend, refuse the path in the filesystem with a deny root, or leave it out of the allowed roots.
62
+
63
+ ## Source layout
64
+
65
+ | Path | Role |
66
+ | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
67
+ | `src/contract/` | The public types, one file for each area. No runtime code. `base.ts` holds the tool-neutral types |
68
+ | `src/core/create-read-tool.ts`, `core/deps.ts` | Checks and resolves the dependencies once, synchronously |
69
+ | `src/core/pipeline.ts` | The stage order, and nothing else |
70
+ | `src/core/call-scope.ts` | Per-read state: the call object, the phase, `fs(call)`, `state(call)`, the listing budget, and abort checks |
71
+ | `src/core/resolve.ts`, `open.ts`, `suggest.ts`, `authorize.ts` | Resolve, open, suggestions on a miss, and the authorize checks |
72
+ | `src/core/sample.ts`, `classify.ts` | The bounded sample and the classifier chain |
73
+ | `src/core/convert.ts`, `convert-source.ts`, `converted.ts`, `directory.ts` | Converter selection, the capped source stream, converted outcomes, and directory reads |
74
+ | `src/core/scan.ts`, `scanner.ts`, `cursor.ts`, `budget.ts` | The scan loop, the line scanner, chunk handling with abort racing, and the checked budget |
75
+ | `src/core/verify.ts`, `observation.ts`, `text-outcome.ts`, `notes.ts` | Change detection, observations, the `ok` outcome, and truncation notes |
76
+ | `src/core/hooks.ts`, `same.ts`, `record.ts` | The hook runner and its rule checks, and the record |
77
+ | `src/core/outcomes.ts`, `extension-error.ts`, `format.ts` | Failures, `EXTENSION_FAILED`, the formatter call, and `textOf` |
78
+ | `src/classifiers/`, `resolve/`, `suggest/`, `authorize/`, `converters/`, `hooks/`, `budget/`, `formatters/` | The built-in helpers, one file for each |
79
+ | `src/signature/`, `src/formats/` | The `./signature` and `./formats` subpaths |
80
+ | `src/state/`, `src/digest/` | `memoryStore` and `sha256Digest`, exported from the root |
81
+
82
+ ## One call, one context
83
+
84
+ The adapter builds a `ReadContext` for each tool call: `{ signal?, callId?, host }`. It extends the tool-neutral `ToolCallContext`. The core passes this object by reference to every stage and every host function, as `ctx.call`. It never reads, copies, freezes, or stores `host`. `host` never appears in the result, the record, notes, or content.
85
+
86
+ `CallScope` holds the per-read state:
87
+
88
+ - `fs(call)` runs at most once, after input validation and before `resolve`. An abort before that point means the filesystem is never built.
89
+ - `state(call)` runs at most once, and only when the core needs the store: to read `previous` for the hooks, or to record an observation.
90
+ - A read has at most two listings. One is for a resolver (`ctx.list()`). The other is for suggestions or a directory converter. A second request for the same slot gets a `denied` list outcome with detail `listing budget spent`. `authorize` with `action: "list"` runs before each listing.
91
+ - A listing never throws. A missing `list()`, a denial, an abort, or a throwing backend becomes an error outcome. A throwing list authorizer is held and raised after the stage that listed, so a resolver cannot swallow it.
92
+ - The signal is checked before each stage and raced against each backend call. An abort gives `ABORTED` with the current phase, and no host code runs after it.
93
+
94
+ ### Tool-neutral base types
95
+
96
+ `src/contract/base.ts` holds the types that the read, write, and bash tools share: `ToolCallContext`, `ToolName`, `Note`, `ToolError`, `ToolMessages`, `ToolHookContext`, `ToolResolveContext`, `PathResolver`, `ResolveOutcome`, `AccessTarget`, `AccessDecision`, `ToolAuthorizer`, `ToolSignature`, and `SignatureDocs`. `@better-fs-tools/write` and `@better-fs-tools/shell` import them from this package, so they have no copy of their own.
97
+
98
+ - `ReadHookContext` extends `ToolHookContext` with `tool: "read"`, the request, and the limits. `ReadResolveContext` adds `paths` and `list()`, so it fits `ToolResolveContext`.
99
+ - Resolvers take a `ToolResolveContext`. The built-in resolvers never need the read request, so they work for every tool.
100
+ - `denyPaths` is a `ToolAuthorizer`. It sees only the fields every target has, and uses `ctx.messages.denied({ path, detail })`, which every tool's catalog has. A `ToolAuthorizer` fits the read `ReadAuthorizer`.
101
+ - The read `ReadAuthorizeDecision` keeps `ReadNote`, so a denial can carry a `retry`. An `AccessDecision` fits it.
102
+
103
+ The public types keep `THost` out of conditional types. `ReadContext<THost>` is an interface with a required `host`. Only the `ReadTool<THost>` parameter type makes the context and `host` optional when `THost` includes `undefined`. With this shape, a helper typed with `unknown` for the host fits a tool with any host type. A conditional context type made `ReadFormatter` and the other contexts invariant in `THost` under TypeScript 7, and then host-free helpers did not fit.
104
+
105
+ ## Why the filesystem returns a handle
106
+
107
+ A contract with separate `stat(path)` and `readBytes(path)` calls cannot close the gap between the check and the read. So `open()` does the whole decision: resolve, check roots and deny roots, check the type, and open. It returns an `OpenFile`. The core calls the same members in the same order for every backend:
108
+
109
+ ```text
110
+ handle.info metadata, from the object that was opened
111
+ handle.bytes() single-use AsyncIterable<Uint8Array>
112
+ handle.verify() did the opened object change?
113
+ handle.close() always, from a finally block
114
+ ```
115
+
116
+ Two capabilities remain, and each one decides one thing:
117
+
118
+ - `streaming: false` adds a `buffered-backend` note.
119
+ - `identity: false` adds a `weak-identity` note, because the observation cannot back a write precondition.
120
+
121
+ An optional `list()` is the list capability. Without it, there are no suggestions and no directory listings.
122
+
123
+ When `open()` refuses a directory, FIFO, socket, or device, it returns a `not-a-file` error with a `kind`. For a directory it also gives a `target` with the resolved and display paths. A directory converter lists `target.resolvedPath`, and `authorize` sees that path. When the target is `null`, the core uses the lexical path.
124
+
125
+ ## Scanning
126
+
127
+ `LineScanner` keeps the current line prefix, the selected view, the decoder state, and a few counters, and nothing else. Memory stays flat for a large file. Lines end at `\n`, `\r\n`, and a bare `\r`. A trailing newline does not add a line, so a file that ends with a newline has exact totals.
128
+
129
+ The view stops at the first of these limits, always at a line boundary:
130
+
131
+ - `lines`: `request.limit` lines were shown.
132
+ - `bytes`: the next line would pass `limits.maxViewBytes` of source text.
133
+ - `budget`: the next line would pass `budget.max`. The budget never stops the first line of a view.
134
+
135
+ Two more reasons can apply to the whole read:
136
+
137
+ - `line-length`: a line was longer than `limits.maxCharsPerLine` and was clamped. `line.sourceChars` keeps the original length.
138
+ - `scan-limit`: `limits.maxScanBytes` was reached. `totals.lines` and `observation.contentId` are then `null`.
139
+
140
+ The scan continues past the view to count lines and hash the content. When the view stops early, the first line that was not shown becomes `continuation.next`, a whole canonical input.
141
+
142
+ View bytes count source text plus one separator byte for each line after the first. They do not count the gutter. So a formatter can change or remove the gutter without changing what fits in a read.
143
+
144
+ If the sample looked like text but a later chunk is not valid UTF-8, the scan stops and the core asks the classifier chain for its `encodingFailure` refusal. Classifiers never run twice.
145
+
146
+ ## Classification and conversion
147
+
148
+ Classifiers are synchronous and see only a bounded sample that is already in memory. The classifier that recognizes a format also owns the refusal note that the model sees, and the code in `ReadUnsupported.code`.
149
+
150
+ Converters come after classification. `accepts(match)` is synchronous, and it is the only place where a converter can decline. `convert` must return an outcome: text, media, or a refusal.
151
+
152
+ A file converter gets a stream that the core owns. The stream starts at byte 0 (the sample, then the rest of the file), counts and hashes the bytes, and stops at `limits.maxConvertBytes` with an internal error. The core turns that error into `unsupported` with code `TOO_LARGE`, also when the converter catches it. When a converter stops before the end, the core reads the rest of the source within the same cap. So `contentId` is always the hash of the whole source, and the size check can run. Converted text then goes through the same `LineScanner` as file text.
153
+
154
+ Media parts over `limits.maxMediaBytes` in total give `TOO_LARGE`. A media outcome has `wholeFileVisible: false`. Its `viewId` hashes the parts.
155
+
156
+ A directory has no handle. The directory converter gets one bounded listing through `CallScope`, and returns text. The text goes through the scanner, so `offset` and `limit` page over entries. A failed listing ends the read with the matching error. A directory result has `observation: null`, because there is nothing to verify.
157
+
158
+ ## Change detection
159
+
160
+ After the scan or the conversion, the core checks the size when it reached the end, and calls `handle.verify()`. A changed file gives `CHANGED_DURING_READ` with a retry of the same range. This runs after an `askUser` prompt too, so an edit during the prompt is caught.
161
+
162
+ ## Hooks
163
+
164
+ Hooks run in order after verification and before the record, for every outcome that has a request. They do not run for `INVALID_INPUT`, which has no request, or for `ABORTED`, because the caller has given up.
165
+
166
+ After each hook the core checks the rules:
167
+
168
+ - The status cannot move from `error` or `unsupported` to `ok` or `media`.
169
+ - `request`, `file`, `classification`, `conversion`, `truncation`, `continuation`, `totals`, and `observation` must be the same values. When the status changes, only the fields that both outcomes have are compared.
170
+
171
+ A hook that throws, returns something that is not an outcome, or breaks a rule gives `EXTENSION_FAILED`.
172
+
173
+ If a hook changed the view lines or the media parts, the core adds a `view-modified` note that names the hook, recomputes `observation.viewId` and `observation.id`, and sets `wholeFileVisible` to `false`. The stored record then matches what the model saw.
174
+
175
+ Hooks are host code. The model cannot choose which files a hook reads, so a hook that reads files uses the host's own permissions. The core does not check those reads.
176
+
177
+ ## Observations and records
178
+
179
+ An observation exists when there is a `digest`:
180
+
181
+ | Field | Value |
182
+ | ------------------ | ------------------------------------------------------------------------------------------------ |
183
+ | `statId` | Hash of the resolved path, identity, size, and modification time |
184
+ | `contentId` | Hash of the source bytes. `null` when the scan stopped before the end. |
185
+ | `viewId` | Hash of what the model saw: the view text, or the media parts |
186
+ | `id` | Hash of `statId`, `contentId`, and `viewId` |
187
+ | `wholeFileVisible` | `true` only when the offset is 1, nothing was truncated or clamped, and no hook changed the view |
188
+
189
+ The record stores these fields with the request range, under the key `file.resolvedPath`. It is schema 2: it also has `origin: "read"`, the backend `version`, and the `digest` id. The write tools store records with `origin: "write"` and `request: null` in the same store, so a later edit knows what the model has seen. The hook runner treats a record of another schema as absent, and `repeatReadGuard` ignores a write record. `record` builds it field by field from the outcome, so nothing from `call` reaches the store. A store whose `get` or `put` fails does not fail the read. A `state` factory that throws does.
190
+
191
+ ## Errors
192
+
193
+ Filesystem adapters return typed reasons, and the core maps them one to one. The core never parses a detail string.
194
+
195
+ | Adapter reason | Result code |
196
+ | ----------------------- | ----------------------- |
197
+ | `not-found` | `NOT_FOUND` |
198
+ | `not-a-file` | `NOT_A_FILE` |
199
+ | `dangerous-path` | `DANGEROUS_PATH` |
200
+ | `outside-allowed-roots` | `OUTSIDE_ALLOWED_ROOTS` |
201
+ | `permission-denied` | `PERMISSION_DENIED` |
202
+ | `denied` | `DENIED` |
203
+ | `too-large` | `TOO_LARGE` |
204
+ | `unsupported` | `UNSUPPORTED_BACKEND` |
205
+ | `aborted` | `ABORTED` |
206
+ | `io` | `IO_ERROR` |
207
+
208
+ The core makes the other failures itself:
209
+
210
+ - `INVALID_INPUT` from `parseReadInput`.
211
+ - `DENIED` from an authorizer. It has `file: null`, so a denial does not show the real path.
212
+ - `CHANGED_DURING_READ` from the size check or `verify()`.
213
+ - `UNSUPPORTED_BACKEND` when no classifier had an opinion.
214
+ - `IO_ERROR` when a filesystem method or the byte stream throws.
215
+ - `EXTENSION_FAILED` when host code throws or breaks a rule. `data.extension` names the dependency (`fs`, `state`, `resolve`, `suggest`, `authorize`, `converters`, `budget`, or `hooks`), `data.phase` names the stage, and `data.id` names the extension object when it has one.
216
+
217
+ A formatter that throws, or returns neither a string nor an array, does not reject the call. The core adds an `extension-failed` warning with `data: { extension: "formatter", id? }` and formats the result with `lineNumberFormatter()`. The status stays as it was.
218
+
219
+ ## Adapters
220
+
221
+ Adapters sit above the core. A `ReadSignature` owns the tool name, the JSON Schema, and the mapping between model input and canonical input. `readSignatureMessages(signature)` makes retry text use the model's names. The structured result always stays canonical.
222
+
223
+ The AI SDK adapter passes `ToolExecutionOptions` as `host`. The Pi adapter passes Pi's `ExtensionContext`, and builds the filesystem for each call from `ctx.cwd` with an `fs` factory. No adapter adds policy of its own, except Pi's rule that the root is always the call's working directory.
224
+
225
+ ## Packages
226
+
227
+ | Package | Depends on |
228
+ | ----------------------------------------- | -------------------------------------------------- |
229
+ | `fs` | nothing |
230
+ | `read` | `fs` |
231
+ | `shell` | `fs`, `read` |
232
+ | `write` | `fs`, `read`, `shell` |
233
+ | `node` | `fs`, `read`, `write`, `shell` |
234
+ | `ai-sdk` | `read`, `write`, `shell`, and the `ai` peer |
235
+ | `pi` | `node`, `read`, `write`, `shell`, and the Pi peers |
236
+ | `cloudflare-shell`, `cloudflare-computer` | `fs` |
237
+ | `just-bash` | `fs`, `shell`, and the `just-bash` peer |
238
+
239
+ `fs` holds the filesystem contract, so a filesystem adapter does not depend on the read tool. No package but `node` and `pi` imports a `node:` module.
240
+
241
+ ## Deliberate omissions
242
+
243
+ - **No alias repair.** The core accepts `path`, `offset`, and `limit` only. Other names belong to a signature.
244
+ - **No Node defaults in the core.** `fs` is required and `digest` defaults to `null`. `createNodeReadTool()` in `@better-fs-tools/node` gives the local defaults. A Workers build cannot pull in `node:fs` by mistake.
245
+ - **No converter path access.** A converter gets the capped stream, never the path.
246
+ - **No nested reads from hooks.** A hook that needs another file reads it with host code.
247
+ - **No write guard.** The precondition check belongs with the write tool that uses the record.
248
+ - **No telemetry.** A host that wants timing can wrap the returned function.
249
+ - **POSIX only.** Windows paths are not supported in this release.