@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.
- package/README.md +119 -84
- package/dist/classifiers/index.d.ts +4 -3
- package/dist/classifiers/index.js +4 -5
- package/dist/contract/base.d.ts +31 -3
- package/dist/contract/deps.d.ts +9 -14
- package/dist/contract/limits.d.ts +1 -1
- package/dist/contract/messages.d.ts +0 -10
- package/dist/contract/result.d.ts +10 -0
- package/dist/contract/state.d.ts +10 -0
- package/dist/core/convert.js +3 -3
- package/dist/core/create-read-tool.d.ts +3 -3
- package/dist/core/create-read-tool.js +3 -3
- package/dist/core/deps.d.ts +2 -2
- package/dist/core/deps.js +17 -6
- package/dist/core/format.js +1 -15
- package/dist/core/limits.js +1 -1
- package/dist/core/messages.js +5 -8
- package/dist/core/notes.d.ts +0 -2
- package/dist/core/notes.js +0 -20
- package/dist/core/observation.d.ts +2 -1
- package/dist/core/observation.js +2 -0
- package/dist/core/record.js +59 -6
- package/dist/core/seen-lines.d.ts +7 -0
- package/dist/core/seen-lines.js +39 -0
- package/dist/core/text-outcome.js +8 -3
- package/dist/index.d.ts +3 -3
- package/dist/signature/definition.d.ts +30 -0
- package/dist/signature/definition.js +48 -0
- package/dist/signature/index.d.ts +2 -0
- package/dist/signature/index.js +1 -0
- package/dist/state/index.d.ts +1 -1
- package/dist/suggest/suggest-file-names.d.ts +5 -1
- package/dist/suggest/suggest-file-names.js +5 -1
- package/docs/architecture.md +19 -17
- package/docs/result-schema.md +24 -21
- package/package.json +2 -2
|
@@ -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
|
}
|
package/dist/contract/state.d.ts
CHANGED
|
@@ -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: {
|
package/dist/core/convert.js
CHANGED
|
@@ -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,
|
|
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
|
|
131
|
+
notes,
|
|
132
132
|
};
|
|
133
133
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReadTool } from "../contract/context.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReadToolOptions, StateNeedsDigest } from "../contract/deps.ts";
|
|
3
3
|
/**
|
|
4
|
-
* Creates the read tool. Throws TypeError on an unknown or malformed
|
|
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>(
|
|
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
|
|
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(
|
|
9
|
-
const resolved = resolveDependencies(
|
|
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");
|
package/dist/core/deps.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FileSystem } from "@better-fs-tools/fs";
|
|
2
|
-
import type { ReadDependencies,
|
|
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:
|
|
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
|
|
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
|
|
27
|
+
throw new TypeError("read tool options must be an object");
|
|
26
28
|
for (const key of Object.keys(deps)) {
|
|
27
|
-
if (!
|
|
28
|
-
throw new TypeError(`Unknown read tool
|
|
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
|
|
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" &&
|
package/dist/core/format.js
CHANGED
|
@@ -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
|
-
|
|
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")
|
package/dist/core/limits.js
CHANGED
|
@@ -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:
|
|
4
|
+
maxViewBytes: 50 * 1_024,
|
|
5
5
|
maxCharsPerLine: 2_000,
|
|
6
6
|
maxScanBytes: 64 * 1_024 * 1_024,
|
|
7
7
|
sampleBytes: 8_192,
|
package/dist/core/messages.js
CHANGED
|
@@ -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
|
|
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)}
|
|
16
|
-
dangerousPath: ({ request, detail }) => `${request.path}
|
|
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
|
|
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 }) => `
|
|
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);
|
package/dist/core/notes.d.ts
CHANGED
|
@@ -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[];
|
package/dist/core/notes.js
CHANGED
|
@@ -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;
|
package/dist/core/observation.js
CHANGED
|
@@ -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) {
|
package/dist/core/record.js
CHANGED
|
@@ -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.
|
|
52
|
+
record = await store.get(key);
|
|
13
53
|
}
|
|
14
54
|
catch {
|
|
15
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
totalsExact
|
|
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,
|
|
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
|
|
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 {
|
|
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";
|
package/dist/signature/index.js
CHANGED
package/dist/state/index.d.ts
CHANGED
|
@@ -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`
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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);
|