@better-fs-tools/read 0.1.0 → 0.3.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.
@@ -150,4 +150,14 @@ export interface ReadObservation {
150
150
  readonly observedAt: string;
151
151
  /** True when offset is 1, nothing was truncated or clamped, and no hook edited the view. */
152
152
  readonly wholeFileVisible: boolean;
153
+ /**
154
+ * The source lines that the view showed in full. null when the view showed no
155
+ * line, clamped a line, came from a converter, or was edited by a hook.
156
+ */
157
+ readonly visibleLines: ReadLineRange | null;
158
+ }
159
+ /** An inclusive range of 1-based source line numbers. */
160
+ export interface ReadLineRange {
161
+ readonly start: number;
162
+ readonly end: number;
153
163
  }
@@ -1,3 +1,4 @@
1
+ import type { ReadLineRange } from "./result.ts";
1
2
  /**
2
3
  * Session state for read observations. A store is a cache: a failed store call
3
4
  * never fails a read.
@@ -26,7 +27,16 @@ export interface ReadRecord {
26
27
  /** For a write record: equal to contentId. */
27
28
  readonly viewId: string;
28
29
  readonly observedAt: string;
30
+ /**
31
+ * True when one read showed the whole file, or when the reads of this
32
+ * contentId together showed every line.
33
+ */
29
34
  readonly wholeFileVisible: boolean;
35
+ /**
36
+ * The lines of this contentId that reads showed in full, sorted and joined.
37
+ * Empty for a write record.
38
+ */
39
+ readonly seenLines: readonly ReadLineRange[];
30
40
  readonly totalsExact: boolean;
31
41
  /** The read range. null for a write record. */
32
42
  readonly request: {
@@ -3,7 +3,6 @@ import { ConvertSource } from "./convert-source.js";
3
3
  import { checkConvertOutcome, firstMediaType, mediaBytes, refusedOutcome, scanConvertedText, tooLarge, } from "./converted.js";
4
4
  import { AbortReadError, raceAbort } from "./cursor.js";
5
5
  import { extensionId } from "./extension-error.js";
6
- import { capabilityNotes } from "./notes.js";
7
6
  import { buildObservation } from "./observation.js";
8
7
  import { ReadStop } from "./outcomes.js";
9
8
  import { textOutcome } from "./text-outcome.js";
@@ -111,7 +110,7 @@ async function drainAndVerify(source, handle, input, throwIfSourceFailed) {
111
110
  await verifyHandle(handle, deps.messages, request, file, scope);
112
111
  }
113
112
  function mediaOutcome(input, classification, parts, notes, source) {
114
- const { deps, fs, request, file, converter } = input;
113
+ const { deps, request, file, converter } = input;
115
114
  return {
116
115
  tool: "read",
117
116
  status: "media",
@@ -127,7 +126,8 @@ function mediaOutcome(input, classification, parts, notes, source) {
127
126
  observedAt: deps.clock().toISOString(),
128
127
  // Media is not source text, so it cannot back a text edit.
129
128
  wholeFileVisible: false,
129
+ visibleLines: null,
130
130
  }),
131
- notes: [...notes, ...capabilityNotes(fs, deps.messages)],
131
+ notes,
132
132
  };
133
133
  }
@@ -1,7 +1,7 @@
1
1
  import type { ReadTool } from "../contract/context.ts";
2
- import type { ReadToolDeps, StateNeedsDigest } from "../contract/deps.ts";
2
+ import type { ReadToolOptions, StateNeedsDigest } from "../contract/deps.ts";
3
3
  /**
4
- * Creates the read tool. Throws TypeError on an unknown or malformed dependency,
4
+ * Creates the read tool. Throws TypeError on an unknown or malformed option,
5
5
  * a missing `fs`, an empty `classifiers` list, or a `state` without a `digest`.
6
6
  */
7
- export declare function createReadTool<THost = undefined>(deps: ReadToolDeps<THost> & StateNeedsDigest): ReadTool<THost>;
7
+ export declare function createReadTool<THost = undefined>(options: ReadToolOptions<THost> & StateNeedsDigest): ReadTool<THost>;
@@ -2,11 +2,11 @@ import { resolveDependencies } from "./deps.js";
2
2
  import { isRecord } from "./input.js";
3
3
  import { runRead } from "./pipeline.js";
4
4
  /**
5
- * Creates the read tool. Throws TypeError on an unknown or malformed dependency,
5
+ * Creates the read tool. Throws TypeError on an unknown or malformed option,
6
6
  * a missing `fs`, an empty `classifiers` list, or a `state` without a `digest`.
7
7
  */
8
- export function createReadTool(deps) {
9
- const resolved = resolveDependencies(deps);
8
+ export function createReadTool(options) {
9
+ const resolved = resolveDependencies(options);
10
10
  const read = async (input, ctx) => {
11
11
  if (ctx !== undefined && !isRecord(ctx)) {
12
12
  throw new TypeError("read context must be an object");
@@ -1,6 +1,6 @@
1
1
  import type { FileSystem } from "@better-fs-tools/fs";
2
- import type { ReadDependencies, ReadToolDeps } from "../contract/deps.ts";
2
+ import type { ReadDependencies, ReadToolOptions } from "../contract/deps.ts";
3
3
  import type { ReadStateStore } from "../contract/state.ts";
4
- export declare function resolveDependencies<THost>(deps: ReadToolDeps<THost>): ReadDependencies<THost>;
4
+ export declare function resolveDependencies<THost>(deps: ReadToolOptions<THost>): ReadDependencies<THost>;
5
5
  export declare function isFileSystem(value: unknown): value is FileSystem;
6
6
  export declare function isStateStore(value: unknown): value is ReadStateStore;
package/dist/core/deps.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { defaultClassifiers } from "../classifiers/index.js";
2
+ import { imageConverter } from "../converters/index.js";
3
+ import { sha256Digest } from "../digest/sha256.js";
2
4
  import { lineNumberFormatter } from "../formatters/index.js";
3
5
  import { defaultSuggest } from "../suggest/index.js";
4
6
  import { isRecord } from "./input.js";
5
7
  import { resolveReadLimits } from "./limits.js";
6
8
  import { resolveReadMessages } from "./messages.js";
7
- const KNOWN_DEPENDENCY_KEYS = new Set([
9
+ const KNOWN_OPTION_KEYS = new Set([
8
10
  "fs",
9
11
  "limits",
10
12
  "messages",
@@ -22,10 +24,10 @@ const KNOWN_DEPENDENCY_KEYS = new Set([
22
24
  ]);
23
25
  export function resolveDependencies(deps) {
24
26
  if (!isRecord(deps))
25
- throw new TypeError("read tool dependencies must be an object");
27
+ throw new TypeError("read tool options must be an object");
26
28
  for (const key of Object.keys(deps)) {
27
- if (!KNOWN_DEPENDENCY_KEYS.has(key))
28
- throw new TypeError(`Unknown read tool dependency: ${key}`);
29
+ if (!KNOWN_OPTION_KEYS.has(key))
30
+ throw new TypeError(`Unknown read tool option: ${key}`);
29
31
  }
30
32
  const { fs } = deps;
31
33
  if (typeof fs !== "function" && !isFileSystem(fs)) {
@@ -51,7 +53,7 @@ export function resolveDependencies(deps) {
51
53
  if (authorize !== null && (!isRecord(authorize) || typeof authorize.authorize !== "function")) {
52
54
  throw new TypeError("authorize must be an authorizer or null");
53
55
  }
54
- const converters = deps.converters === undefined ? [] : deps.converters;
56
+ const converters = deps.converters === undefined ? [imageConverter()] : deps.converters;
55
57
  if (!Array.isArray(converters) || !converters.every(isConverter)) {
56
58
  throw new TypeError("converters must be an array of file and directory converters");
57
59
  }
@@ -70,7 +72,10 @@ export function resolveDependencies(deps) {
70
72
  if (state !== null && typeof state !== "function" && !isStateStore(state)) {
71
73
  throw new TypeError("state must be a store, a function that returns one, or null");
72
74
  }
73
- const digest = deps.digest ?? null;
75
+ const digest = deps.digest === undefined ? sha256Digest() : deps.digest;
76
+ if (digest !== null && !isDigest(digest)) {
77
+ throw new TypeError("digest must have an id, create, and hash, or be null");
78
+ }
74
79
  if (state !== null && digest === null) {
75
80
  throw new TypeError("state needs a digest: records name the digest that made them");
76
81
  }
@@ -113,6 +118,12 @@ function isViewBudget(value) {
113
118
  function isHook(value) {
114
119
  return isRecord(value) && typeof value.id === "string" && typeof value.afterRead === "function";
115
120
  }
121
+ function isDigest(value) {
122
+ return (isRecord(value) &&
123
+ typeof value.id === "string" &&
124
+ typeof value.create === "function" &&
125
+ typeof value.hash === "function");
126
+ }
116
127
  export function isStateStore(value) {
117
128
  return (isRecord(value) &&
118
129
  typeof value.get === "function" &&
@@ -1,5 +1,4 @@
1
1
  import { lineNumberFormatter } from "../formatters/index.js";
2
- import { extensionId } from "./extension-error.js";
3
2
  export function formatResult(deps, call, outcome) {
4
3
  const ctx = {
5
4
  digest: deps.digest,
@@ -14,20 +13,7 @@ export function formatResult(deps, call, outcome) {
14
13
  catch { }
15
14
  if (content !== null)
16
15
  return { ...outcome, content };
17
- const id = extensionId(deps.formatter);
18
- const failed = {
19
- ...outcome,
20
- notes: [
21
- ...outcome.notes,
22
- {
23
- code: "extension-failed",
24
- severity: "warning",
25
- message: deps.messages.formatterFailed({ formatter: id ?? "formatter" }),
26
- data: id === null ? { extension: "formatter" } : { extension: "formatter", id },
27
- },
28
- ],
29
- };
30
- return { ...failed, content: toContent(lineNumberFormatter().format(failed, ctx)) ?? [] };
16
+ return { ...outcome, content: toContent(lineNumberFormatter().format(outcome, ctx)) ?? [] };
31
17
  }
32
18
  function toContent(output) {
33
19
  if (typeof output === "string")
@@ -1,7 +1,7 @@
1
1
  /** The default read limits. They are not ceilings: a host may raise any of them. */
2
2
  export const defaultReadLimits = Object.freeze({
3
3
  maxLines: 2_000,
4
- maxViewBytes: 128 * 1_024,
4
+ maxViewBytes: 50 * 1_024,
5
5
  maxCharsPerLine: 2_000,
6
6
  maxScanBytes: 64 * 1_024 * 1_024,
7
7
  sampleBytes: 8_192,
@@ -6,14 +6,14 @@ const catalog = {
6
6
  offsetUnreached: ({ request, reachedLine, retry }) => `Scanning stopped before line ${request.offset} was reached. Retry from line ${reachedLine} with ${retry}.`,
7
7
  lineClamped: ({ lines, total, maxChars }) => `Source line${total === 1 ? "" : "s"} ${lines.join(", ")}${total > lines.length ? ", …" : ""} exceeded ${maxChars} characters and ${total === 1 ? "was" : "were"} clamped.`,
8
8
  limitClamped: ({ requested, max }) => `The requested line count of ${requested} is over the maximum, so at most ${max} lines are shown.`,
9
- scanLimit: ({ maxScanBytes }) => `Scanning stopped at ${maxScanBytes} bytes; total lines and raw content identity are unknown.`,
9
+ scanLimit: ({ maxScanBytes }) => `Scanning stopped at ${maxScanBytes} bytes, so the total line count is unknown.`,
10
10
  empty: ({ path }) => `${path} is empty (0 bytes); no retry is needed.`,
11
11
  notFound: ({ request, suggestions }) => suggestions.length === 0
12
12
  ? `${request.path} was not found.`
13
13
  : `${request.path} was not found. Nearby names: ${quoted(suggestions)}.`,
14
14
  pathRepaired: ({ from, to }) => `The requested filename ${JSON.stringify(from)} was repaired to the unique Unicode-equivalent path ${JSON.stringify(to)}.`,
15
- notAFile: ({ request, kind }) => `${request.path} is a ${kindLabel(kind)}; directories, FIFOs, sockets, and devices are refused before any content read.`,
16
- dangerousPath: ({ request, detail }) => `${request.path} belongs to a refused policy class${detail === null ? "" : ` (${detail})`}.`,
15
+ notAFile: ({ request, kind }) => `${request.path} is a ${kindLabel(kind)}.`,
16
+ dangerousPath: ({ request, detail }) => `${request.path} is refused by policy${detail === null ? "" : ` (${detail})`}. Choose another path.`,
17
17
  outsideAllowedRoots: ({ request }) => `${request.path} is outside every configured allowed root.`,
18
18
  permissionDenied: ({ request }) => `Permission was denied for ${request.path}.`,
19
19
  denied: ({ path, detail }) => `${path} was refused by policy${detail === null ? "" : ` (${detail})`}.`,
@@ -26,15 +26,12 @@ const catalog = {
26
26
  : `The media converted from ${request.path} is larger than the ${limit}-byte media limit, so it was not returned.`;
27
27
  },
28
28
  changedDuringRead: ({ request, retry }) => `${request.path} changed while it was being read. Read it again with ${retry} before relying on this result.`,
29
- aborted: ({ phase }) => `The read was aborted during the ${phase} phase; no observation was recorded.`,
29
+ aborted: ({ phase }) => `The read was aborted during the ${phase} phase, so nothing was read.`,
30
30
  invalidInput: ({ detail }) => `The read input was rejected: ${detail}`,
31
31
  extensionFailed: ({ request, extension, phase }) => `The ${extension} extension failed during the ${phase} phase while reading ${request.path}; this is a host problem rather than a property of the file.`,
32
32
  ioError: ({ request }) => `The backend failed while reading ${request === null ? "the requested path" : request.path}.`,
33
- unsupportedBackend: ({ request, detail }) => `No classifier had an opinion about ${request.path}${detail === null ? "" : ` (${detail})`}; this is a configuration problem rather than a property of the file.`,
33
+ unsupportedBackend: ({ request, detail }) => `This host cannot read ${request.path}${detail === null ? "" : ` (${detail})`}. Do not retry.`,
34
34
  viewModified: ({ hook }) => `The ${hook} hook changed this view, so it is not the exact file text.`,
35
- weakIdentity: ({ backend }) => `The ${backend} backend has no stable identity, so this observation cannot back a write precondition.`,
36
- bufferedBackend: ({ backend }) => `The ${backend} backend buffers whole objects instead of streaming them.`,
37
- formatterFailed: ({ formatter }) => `The ${formatter} formatter failed, so the default formatter formatted this result.`,
38
35
  };
39
36
  /** The default wording of every read message. */
40
37
  export const defaultReadMessages = Object.freeze(catalog);
@@ -1,4 +1,3 @@
1
- import type { FileSystem } from "@better-fs-tools/fs";
2
1
  import type { ReadRequest } from "../contract/input.ts";
3
2
  import type { ReadLimits } from "../contract/limits.ts";
4
3
  import type { ReadMessageCatalog } from "../contract/messages.ts";
@@ -13,4 +12,3 @@ export interface ViewNoteContext {
13
12
  }
14
13
  export declare function buildViewNotes(context: ViewNoteContext): ReadNote[];
15
14
  export declare function emptyNote(messages: Readonly<ReadMessageCatalog>, path: string): ReadNote;
16
- export declare function capabilityNotes(fs: FileSystem, messages: Readonly<ReadMessageCatalog>): ReadNote[];
@@ -89,26 +89,6 @@ export function buildViewNotes(context) {
89
89
  export function emptyNote(messages, path) {
90
90
  return { code: "empty", severity: "info", message: messages.empty({ path }) };
91
91
  }
92
- export function capabilityNotes(fs, messages) {
93
- const notes = [];
94
- if (!fs.capabilities.identity) {
95
- notes.push({
96
- code: "weak-identity",
97
- severity: "info",
98
- message: messages.weakIdentity({ backend: fs.id }),
99
- data: { backend: fs.id },
100
- });
101
- }
102
- if (!fs.capabilities.streaming) {
103
- notes.push({
104
- code: "buffered-backend",
105
- severity: "info",
106
- message: messages.bufferedBackend({ backend: fs.id }),
107
- data: { backend: fs.id },
108
- });
109
- }
110
- return notes;
111
- }
112
92
  function retryFrom(request, offset) {
113
93
  return { path: request.path, offset, limit: request.limit };
114
94
  }
@@ -1,11 +1,12 @@
1
1
  import type { Digest } from "../contract/digest.ts";
2
- import type { ContentPart, FileInfo, ReadObservation } from "../contract/result.ts";
2
+ import type { ContentPart, FileInfo, ReadLineRange, ReadObservation } from "../contract/result.ts";
3
3
  export interface ObservationInput {
4
4
  readonly file: FileInfo;
5
5
  readonly contentId: string | null;
6
6
  readonly view: string | readonly ContentPart[];
7
7
  readonly observedAt: string;
8
8
  readonly wholeFileVisible: boolean;
9
+ readonly visibleLines: ReadLineRange | null;
9
10
  }
10
11
  export declare function buildObservation(digest: Digest | null, input: ObservationInput): ReadObservation | null;
11
12
  export declare function withEditedView(digest: Digest, observation: ReadObservation, view: string | readonly ContentPart[]): ReadObservation;
@@ -16,6 +16,7 @@ export function buildObservation(digest, input) {
16
16
  viewId,
17
17
  observedAt: input.observedAt,
18
18
  wholeFileVisible: input.wholeFileVisible,
19
+ visibleLines: input.visibleLines,
19
20
  };
20
21
  }
21
22
  export function withEditedView(digest, observation, view) {
@@ -25,6 +26,7 @@ export function withEditedView(digest, observation, view) {
25
26
  id: observationId(digest, observation.statId, observation.contentId, viewId),
26
27
  viewId,
27
28
  wholeFileVisible: false,
29
+ visibleLines: null,
28
30
  };
29
31
  }
30
32
  function viewIdOf(digest, view) {
@@ -1,3 +1,10 @@
1
+ import { isRecord } from "./input.js";
2
+ import { coversLines, joinLineRanges, storedLineRanges } from "./seen-lines.js";
3
+ /**
4
+ * The record step of each read waits for the step before it on the same store
5
+ * and key, so parallel ranged reads of one file all join the stored ranges.
6
+ */
7
+ const pending = new WeakMap();
1
8
  export async function recordOutcome(scope, outcome) {
2
9
  if (outcome.status !== "ok" && outcome.status !== "media")
3
10
  return;
@@ -8,16 +15,62 @@ export async function recordOutcome(scope, outcome) {
8
15
  const store = scope.stateStore();
9
16
  if (store === null)
10
17
  return;
18
+ const key = outcome.file.resolvedPath;
19
+ await inTurn(store, key, async () => {
20
+ try {
21
+ const previous = await sameContentRecord(store, key, digest.id, observation.contentId);
22
+ await store.put(key, toRecord(outcome, observation, digest.id, previous));
23
+ }
24
+ catch {
25
+ // Session state is a cache: a failing store never fails the read.
26
+ }
27
+ });
28
+ }
29
+ async function inTurn(store, key, step) {
30
+ let keys = pending.get(store);
31
+ if (keys === undefined) {
32
+ keys = new Map();
33
+ pending.set(store, keys);
34
+ }
35
+ const before = keys.get(key) ?? Promise.resolve();
36
+ const current = before.then(step);
37
+ keys.set(key, current);
38
+ try {
39
+ await current;
40
+ }
41
+ finally {
42
+ if (keys.get(key) === current)
43
+ keys.delete(key);
44
+ }
45
+ }
46
+ /** The stored record, when it describes the same bytes with the same digest. */
47
+ async function sameContentRecord(store, key, digest, contentId) {
48
+ if (contentId === null)
49
+ return null;
50
+ let record;
11
51
  try {
12
- await store.put(outcome.file.resolvedPath, toRecord(outcome, observation, digest.id));
52
+ record = await store.get(key);
13
53
  }
14
54
  catch {
15
- // Session state is a cache: a failing store never fails the read.
55
+ return null;
16
56
  }
57
+ if (!isRecord(record) || record.schema !== 2 || record.digest !== digest)
58
+ return null;
59
+ return record.contentId === contentId ? record : null;
17
60
  }
18
61
  /** Copies fields one by one so nothing from the host `call` reaches the store. */
19
- function toRecord(outcome, observation, digest) {
62
+ function toRecord(outcome, observation, digest, previous) {
20
63
  const { file, request } = outcome;
64
+ const seenLines = joinLineRanges([
65
+ ...storedLineRanges(previous?.seenLines),
66
+ ...(observation.visibleLines === null ? [] : [observation.visibleLines]),
67
+ ]);
68
+ // Media is only returned from a whole source, so its source totals are exact.
69
+ const totalsExact = outcome.status === "ok" ? outcome.totals.exact : true;
70
+ const totalLines = outcome.status === "ok" ? outcome.totals.lines : null;
71
+ const wholeFileVisible = observation.wholeFileVisible ||
72
+ previous?.wholeFileVisible === true ||
73
+ (totalsExact && totalLines !== null && coversLines(seenLines, totalLines));
21
74
  return {
22
75
  schema: 2,
23
76
  origin: "read",
@@ -29,9 +82,9 @@ function toRecord(outcome, observation, digest) {
29
82
  contentId: observation.contentId,
30
83
  viewId: observation.viewId,
31
84
  observedAt: observation.observedAt,
32
- wholeFileVisible: observation.wholeFileVisible,
33
- // Media is only returned from a whole source, so its source totals are exact.
34
- totalsExact: outcome.status === "ok" ? outcome.totals.exact : true,
85
+ wholeFileVisible,
86
+ seenLines,
87
+ totalsExact,
35
88
  request: { offset: request.offset, limit: request.limit },
36
89
  };
37
90
  }
@@ -0,0 +1,7 @@
1
+ import type { ReadLineRange } from "../contract/result.ts";
2
+ /** Sorts the ranges and joins ranges that overlap or touch. */
3
+ export declare function joinLineRanges(ranges: readonly ReadLineRange[]): ReadLineRange[];
4
+ /** True when joined ranges show every line from 1 to totalLines. */
5
+ export declare function coversLines(joined: readonly ReadLineRange[], totalLines: number): boolean;
6
+ /** Reads ranges from a stored record. Anything malformed counts as no range. */
7
+ export declare function storedLineRanges(value: unknown): ReadLineRange[];
@@ -0,0 +1,39 @@
1
+ /** Sorts the ranges and joins ranges that overlap or touch. */
2
+ export function joinLineRanges(ranges) {
3
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
4
+ const joined = [];
5
+ for (const range of sorted) {
6
+ const last = joined.at(-1);
7
+ if (last !== undefined && range.start <= last.end + 1) {
8
+ joined[joined.length - 1] = { start: last.start, end: Math.max(last.end, range.end) };
9
+ }
10
+ else {
11
+ joined.push({ start: range.start, end: range.end });
12
+ }
13
+ }
14
+ return joined;
15
+ }
16
+ /** True when joined ranges show every line from 1 to totalLines. */
17
+ export function coversLines(joined, totalLines) {
18
+ if (totalLines === 0)
19
+ return true;
20
+ const first = joined[0];
21
+ return first !== undefined && first.start <= 1 && first.end >= totalLines;
22
+ }
23
+ /** Reads ranges from a stored record. Anything malformed counts as no range. */
24
+ export function storedLineRanges(value) {
25
+ if (!Array.isArray(value))
26
+ return [];
27
+ const ranges = [];
28
+ for (const item of value) {
29
+ if (item === null || typeof item !== "object")
30
+ return [];
31
+ const { start, end } = item;
32
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end))
33
+ return [];
34
+ if (start < 1 || end < start)
35
+ return [];
36
+ ranges.push({ start: start, end: end });
37
+ }
38
+ return ranges;
39
+ }
@@ -1,4 +1,4 @@
1
- import { buildViewNotes, capabilityNotes, emptyNote } from "./notes.js";
1
+ import { buildViewNotes, emptyNote } from "./notes.js";
2
2
  import { buildObservation } from "./observation.js";
3
3
  export function textOutcome(input) {
4
4
  const { deps, fs, request, file, classification, conversion, scan } = input;
@@ -19,6 +19,11 @@ export function textOutcome(input) {
19
19
  !continuationAvailable &&
20
20
  !scanCapped &&
21
21
  scanner.clampedLines.length === 0;
22
+ const endLine = scanner.lines.length === 0 ? request.offset - 1 : request.offset + scanner.lines.length - 1;
23
+ // Converted lines are not source lines, so they cannot join a range of the source.
24
+ const visibleLines = conversion === null && scanner.lines.length > 0 && scanner.clampedLines.length === 0
25
+ ? { start: request.offset, end: endLine }
26
+ : null;
22
27
  return {
23
28
  tool: "read",
24
29
  status: "ok",
@@ -29,7 +34,7 @@ export function textOutcome(input) {
29
34
  view: {
30
35
  lines: scanner.lines,
31
36
  startLine: request.offset,
32
- endLine: scanner.lines.length === 0 ? request.offset - 1 : request.offset + scanner.lines.length - 1,
37
+ endLine,
33
38
  bytes: scanner.viewBytes,
34
39
  partial: request.offset !== 1 || continuationAvailable || reasons.length > 0,
35
40
  },
@@ -54,6 +59,7 @@ export function textOutcome(input) {
54
59
  view: scanner.viewText,
55
60
  observedAt: deps.clock().toISOString(),
56
61
  wholeFileVisible,
62
+ visibleLines,
57
63
  }),
58
64
  notes: [
59
65
  ...buildViewNotes({
@@ -67,7 +73,6 @@ export function textOutcome(input) {
67
73
  ...(conversion === null && reachedEof && scannedBytes === 0
68
74
  ? [emptyNote(deps.messages, file.displayPath)]
69
75
  : []),
70
- ...(fs === null ? [] : capabilityNotes(fs, deps.messages)),
71
76
  ],
72
77
  };
73
78
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export type { Classification, ClassificationSample, Classifier } from "./contract/classify.ts";
2
2
  export type { BuiltInUnsupportedCode, DefaultClassifierOptions, NoteContext, NoteOverride, TextClassification, UnsupportedClassification, } from "./contract/classify.ts";
3
- export type { AccessDecision, AccessTarget, Note, SignatureDocs, ToolAuthorizer, ToolCallContext, ToolError, ToolHookContext, ToolMessages, ToolName, ToolResolveContext, ToolSignature, } from "./contract/base.ts";
3
+ export type { AccessDecision, AccessTarget, Note, SignatureDocs, ToolAuthorizer, ToolCallContext, ToolDefinition, ToolError, ToolHookContext, ToolMessages, ToolName, ToolResolveContext, ToolSignature, } from "./contract/base.ts";
4
4
  export type { ReadContext, ReadTool } from "./contract/context.ts";
5
- export type { ReadDependencies, ReadToolDeps, StateNeedsDigest, StateNeedsDigestOrDefault, } from "./contract/deps.ts";
5
+ export type { ReadToolOptions, StateNeedsDigest } from "./contract/deps.ts";
6
6
  export type { Clock, Digest, DigestStream } from "./contract/digest.ts";
7
7
  export type { AfterReadContext, Converter, ConverterMatch, ConvertOutcome, DirectoryConverter, DirectoryConvertInput, FileConverter, FileConvertInput, PathResolver, ReadAuthorizeDecision, ReadAuthorizer, ReadAuthorizeTarget, ReadHook, ReadHookContext, ReadResolveContext, ResolveOutcome, Suggest, SuggestContext, ViewBudget, } from "./contract/extensions.ts";
8
8
  export type { ReadFormatContext, ReadFormatter } from "./contract/format.ts";
@@ -10,7 +10,7 @@ export type { ReadInput, ReadRequest } from "./contract/input.ts";
10
10
  export type { JsonObject, JsonValue } from "./contract/json.ts";
11
11
  export type { ReadLimits } from "./contract/limits.ts";
12
12
  export type { ReadMessageCatalog, ReadPhase } from "./contract/messages.ts";
13
- export type { ClassificationInfo, Confidence, ContentPart, ConversionInfo, FileInfo, MediaPart, ReadContinuation, ReadError, ReadErrorCode, ReadFailure, ReadLine, ReadMedia, ReadNote, ReadObservation, ReadOk, ReadReport, ReadResult, ReadTotals, ReadTruncation, ReadUnsupported, ReadView, TextPart, TruncationReason, } from "./contract/result.ts";
13
+ export type { ClassificationInfo, Confidence, ContentPart, ConversionInfo, FileInfo, MediaPart, ReadContinuation, ReadError, ReadErrorCode, ReadFailure, ReadLine, ReadLineRange, ReadMedia, ReadNote, ReadObservation, ReadOk, ReadReport, ReadResult, ReadTotals, ReadTruncation, ReadUnsupported, ReadView, TextPart, TruncationReason, } from "./contract/result.ts";
14
14
  export type { ReadRecord, ReadStateStore } from "./contract/state.ts";
15
15
  export { createReadTool } from "./core/create-read-tool.ts";
16
16
  export { parseReadInput } from "./core/input.ts";
@@ -0,0 +1,30 @@
1
+ import type { ToolDefinition } from "../contract/base.ts";
2
+ import type { ReadToolOptions, StateNeedsDigest } from "../contract/deps.ts";
3
+ import type { Digest } from "../contract/digest.ts";
4
+ import type { ReadFormatter } from "../contract/format.ts";
5
+ import type { ReadInput } from "../contract/input.ts";
6
+ import type { ReadLimits } from "../contract/limits.ts";
7
+ import type { ReadResult } from "../contract/result.ts";
8
+ import type { ReadParam, ReadSignature } from "./contract.ts";
9
+ /** The read tool's options, plus the signature the model sees. */
10
+ export type ReadToolDefinitionOptions<THost = undefined> = ReadToolOptions<THost> & StateNeedsDigest & {
11
+ /** Default defaultReadSignature(). */
12
+ readonly signature?: ReadSignature;
13
+ };
14
+ /**
15
+ * The read tool bound to its signature. `limits`, `formatter`, and `digest`
16
+ * are the ones the tool uses, for a host that formats the result again.
17
+ */
18
+ export interface ReadToolDefinition<THost = undefined> extends ToolDefinition<ReadInput, ReadResult, THost, ReadParam> {
19
+ readonly signature: ReadSignature;
20
+ readonly limits: Readonly<ReadLimits>;
21
+ readonly formatter: ReadFormatter<THost>;
22
+ readonly digest: Digest | null;
23
+ }
24
+ /**
25
+ * Creates the read tool and binds it to a signature, default
26
+ * defaultReadSignature(). The signature's retry text goes under
27
+ * `options.messages`, so a host message still wins. Throws TypeError where
28
+ * createReadTool() does.
29
+ */
30
+ export declare function readToolDefinition<THost = undefined>(options: ReadToolDefinitionOptions<THost>): ReadToolDefinition<THost>;
@@ -0,0 +1,48 @@
1
+ import { createReadTool } from "../core/create-read-tool.js";
2
+ import { parseReadInput } from "../core/input.js";
3
+ import { resolveReadLimits } from "../core/limits.js";
4
+ import { sha256Digest } from "../digest/sha256.js";
5
+ import { lineNumberFormatter } from "../formatters/index.js";
6
+ import { readSignatureMessages } from "./messages.js";
7
+ import { defaultReadSignature } from "./offset-limit.js";
8
+ /**
9
+ * Creates the read tool and binds it to a signature, default
10
+ * defaultReadSignature(). The signature's retry text goes under
11
+ * `options.messages`, so a host message still wins. Throws TypeError where
12
+ * createReadTool() does.
13
+ */
14
+ export function readToolDefinition(options) {
15
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
16
+ throw new TypeError("readToolDefinition options must be an object");
17
+ }
18
+ const { signature = defaultReadSignature(), ...rest } = options;
19
+ const limits = resolveReadLimits(rest.limits);
20
+ const formatter = rest.formatter ?? lineNumberFormatter();
21
+ const digest = rest.digest === undefined ? sha256Digest() : rest.digest;
22
+ const read = createReadTool({
23
+ ...rest,
24
+ limits,
25
+ formatter,
26
+ digest,
27
+ messages: { ...readSignatureMessages(signature), ...rest.messages },
28
+ });
29
+ return bindReadTool(signature, read, { limits, formatter, digest });
30
+ }
31
+ function bindReadTool(signature, read, resolved) {
32
+ return Object.freeze({
33
+ name: signature.name,
34
+ description: signature.description,
35
+ inputSchema: signature.schema,
36
+ ...(signature.grammar === undefined ? {} : { grammar: signature.grammar }),
37
+ signature,
38
+ limits: resolved.limits,
39
+ formatter: resolved.formatter,
40
+ digest: resolved.digest,
41
+ validate(modelInput) {
42
+ const input = signature.toInput(modelInput);
43
+ parseReadInput(input, resolved.limits);
44
+ return input;
45
+ },
46
+ execute: async (modelInput, ctx) => read(signature.toInput(modelInput), ctx),
47
+ });
48
+ }
@@ -1,4 +1,6 @@
1
1
  export type { DefaultReadModelInput, LineRangeParam, ReadParam, ReadSignature, } from "./contract.ts";
2
+ export { readToolDefinition } from "./definition.ts";
3
+ export type { ReadToolDefinition, ReadToolDefinitionOptions } from "./definition.ts";
2
4
  export { lineRangeSignature } from "./line-range.ts";
3
5
  export { readSignatureMessages } from "./messages.ts";
4
6
  export { defaultReadSignature } from "./offset-limit.ts";
@@ -1,3 +1,4 @@
1
+ export { readToolDefinition } from "./definition.js";
1
2
  export { lineRangeSignature } from "./line-range.js";
2
3
  export { readSignatureMessages } from "./messages.js";
3
4
  export { defaultReadSignature } from "./offset-limit.js";
@@ -5,7 +5,7 @@ export interface MemoryStoreOptions {
5
5
  readonly maxEntries?: number;
6
6
  /** Default 30 minutes. */
7
7
  readonly ttlMs?: number;
8
- /** The same clock type as the tools' `clock` dependency. Default the time now. */
8
+ /** The same clock type as the tools' `clock` option. Default the time now. */
9
9
  readonly clock?: Clock;
10
10
  }
11
11
  /**
@@ -1,2 +1,6 @@
1
- /** Up to `maximum` names close to `requested`: same stem, a shared prefix, or a small edit distance. */
1
+ /**
2
+ * Up to `maximum` names close to `requested`, closest first. The match ignores case.
3
+ * The rank is: the same name in another case, then the same stem, then a shared prefix,
4
+ * then an edit distance of 3 or less. Names with the same rank sort by name.
5
+ */
2
6
  export declare function suggestFileNames(requested: string, names: readonly string[], maximum: number): readonly string[];
@@ -1,4 +1,8 @@
1
- /** Up to `maximum` names close to `requested`: same stem, a shared prefix, or a small edit distance. */
1
+ /**
2
+ * Up to `maximum` names close to `requested`, closest first. The match ignores case.
3
+ * The rank is: the same name in another case, then the same stem, then a shared prefix,
4
+ * then an edit distance of 3 or less. Names with the same rank sort by name.
5
+ */
2
6
  export function suggestFileNames(requested, names, maximum) {
3
7
  const requestedLower = requested.toLowerCase();
4
8
  const requestedStem = stem(requestedLower);