pi-posthorse 0.10.0 → 0.10.1
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 +17 -13
- package/package.json +5 -4
- package/src/budget.ts +2 -2
- package/src/files.ts +31 -11
- package/src/history-search.ts +31 -15
- package/src/history.ts +1 -1
- package/src/notes.ts +116 -45
- package/src/paging.ts +1 -1
- package/src/recovery-results.ts +2 -7
- package/src/recovery.ts +4 -3
- package/src/session-files.ts +37 -24
- package/src/session-lines.ts +108 -0
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+

|
|
6
6
|
|
|
7
7
|
## Install and start
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Check the [Node.js and Pi requirements](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#install) before installing. Posthorse supports official Pi and [fitchmultz/pi](https://github.com/fitchmultz/pi).
|
|
10
10
|
|
|
11
11
|
Update an older Pi installation before you install Posthorse. Run these commands:
|
|
12
12
|
|
|
@@ -27,9 +27,13 @@ Posthorse can nudge the model to update task notes before rollover, then continu
|
|
|
27
27
|
|
|
28
28
|
Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence. It never calls a summarization model. The model must restore notes and check live state before it continues. Optional `new_context` deliberately retains no prior messages; manual `/compact` uses Pi's normal summarization.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Posthorse does not schedule rollover when a provider cache expires. An idle session below the
|
|
31
|
+
rollover threshold keeps its context; use an explicit `new_context` when a deliberate clean reset
|
|
32
|
+
is useful. See [idle sessions and cache expiry](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#idle-sessions-and-cache-expiry).
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
See the [changelog](https://github.com/fitchmultz/pi-posthorse/blob/main/CHANGELOG.md) for release history. Default-branch documentation may describe changes not yet published.
|
|
35
|
+
|
|
36
|
+
Recoverable history is not guaranteed model recall. See the [practical guarantees and limits](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/context-recovery.md) for the stock-Pi comparison, search scope, checkpoints, privacy, concurrency and cost, or the [rollover reference](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#how-it-works) for implementation details.
|
|
33
37
|
|
|
34
38
|
## Tools
|
|
35
39
|
|
|
@@ -44,7 +48,7 @@ These tools are available to the model:
|
|
|
44
48
|
|
|
45
49
|
Expand a tool card with Ctrl+O. In fullscreen mode, click a tool card to expand it. A `new_context` card shows a request. Pi's `Compacted from … tokens` card confirms the rollover.
|
|
46
50
|
|
|
47
|
-
See [tool examples and paging details](docs/reference.md#tools).
|
|
51
|
+
See [tool examples and paging details](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#tools).
|
|
48
52
|
|
|
49
53
|
## Settings and limits
|
|
50
54
|
|
|
@@ -60,10 +64,10 @@ Two Posthorse copies can register the same tools and compete for rollover. Keep
|
|
|
60
64
|
|
|
61
65
|
Multiple compaction-policy extensions can make rollover depend on load order. Enable only one.
|
|
62
66
|
|
|
63
|
-
Oversized input and some overflow or truncated responses can reach the provider before automatic recovery. Save task state in notes.
|
|
67
|
+
Oversized input and some overflow or truncated responses can reach the provider before automatic recovery. Save task state in notes. Posthorse's automatic and explicit rollovers do not stop background work outside the foreground tool batch. Manual `/compact` follows Pi's abort behavior instead.
|
|
64
68
|
|
|
65
|
-
See [size limits and extension order](docs/reference.md#settings).
|
|
66
|
-
Check the [legacy-session details](docs/reference.md#official-pi-and-the-fork) before you move old fork sessions to official Pi.
|
|
69
|
+
See [size limits and extension order](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#settings).
|
|
70
|
+
Check the [legacy-session details](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#official-pi-and-the-fork) before you move old fork sessions to official Pi.
|
|
67
71
|
|
|
68
72
|
## Update or remove
|
|
69
73
|
|
|
@@ -72,7 +76,7 @@ pi update npm:pi-posthorse
|
|
|
72
76
|
pi remove npm:pi-posthorse
|
|
73
77
|
```
|
|
74
78
|
|
|
75
|
-
Removal leaves notes and Pi session history in place. See the [installation reference](docs/reference.md#install) for Git sources.
|
|
79
|
+
Removal leaves notes and Pi session history in place. See the [installation reference](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#install) for Git sources.
|
|
76
80
|
|
|
77
81
|
## Data and privacy
|
|
78
82
|
|
|
@@ -80,14 +84,14 @@ Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/
|
|
|
80
84
|
|
|
81
85
|
History can include user messages, assistant thinking, tool arguments, errors, and images. Content you retrieve enters the context of your selected model and provider.
|
|
82
86
|
|
|
83
|
-
See [storage and privacy details](docs/reference.md#data-and-privacy).
|
|
84
|
-
For cost diagnosis, see the [cached-token investigation](ARTIFACTS/investigation-report.md).
|
|
87
|
+
See [storage and privacy details](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#data-and-privacy).
|
|
88
|
+
For cost diagnosis, see the [cached-token investigation](https://github.com/fitchmultz/pi-posthorse/blob/main/ARTIFACTS/investigation-report.md).
|
|
85
89
|
It does not establish the cause of a production bill or a measured cost improvement.
|
|
86
90
|
|
|
87
91
|
## Development
|
|
88
92
|
|
|
89
|
-
See [development and host qualification](docs/development.md) for setup, tests, and releases.
|
|
90
|
-
See [code quality](docs/quality.md) for the required checks.
|
|
93
|
+
See [development and host qualification](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/development.md) for setup, tests, and releases.
|
|
94
|
+
See [code quality](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/quality.md) for the required checks.
|
|
91
95
|
|
|
92
96
|
## License
|
|
93
97
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-posthorse",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
|
|
6
6
|
"keywords": [
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"check": "tsc",
|
|
35
35
|
"quality:prepare": "node scripts/quality-prepare.ts",
|
|
36
|
-
"quality:policy": "node scripts/quality-policy.ts",
|
|
36
|
+
"quality:policy": "node scripts/quality-policy.ts && node scripts/config-policy.ts",
|
|
37
37
|
"quality:probes": "npm run quality:prepare && node --test test/quality*.test.ts",
|
|
38
38
|
"lint": "npm run quality:prepare && npm run quality:policy && oxlint --deny-warnings .",
|
|
39
39
|
"lint:agent": "npm run quality:prepare && npm run quality:policy && oxlint --deny-warnings --format=agent .",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"format": "oxfmt --write .",
|
|
42
42
|
"format:check": "oxfmt --check .",
|
|
43
43
|
"build": "npm pack --dry-run",
|
|
44
|
-
"verify": "npm run lint && npm run format:check && npm run check:compat && npm run build",
|
|
44
|
+
"verify": "node scripts/toolchain-policy.ts && npm run lint && npm run format:check && npm run check:compat && npm run build",
|
|
45
45
|
"check:compat": "npm run check && npm test",
|
|
46
46
|
"test:native": "node --import ./test/pi-loader.ts --test test/host.test.mjs",
|
|
47
47
|
"test": "npm run quality:prepare && node --import ./test/pi-loader.ts --test test/*.test.ts test/host.test.mjs",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"oxlint": "1.87.0",
|
|
74
74
|
"oxlint-tsgolint": "7.0.2003",
|
|
75
75
|
"typebox": "1.3.27",
|
|
76
|
-
"typescript": "7.0.2"
|
|
76
|
+
"typescript": "7.0.2",
|
|
77
|
+
"yaml": "2.9.1"
|
|
77
78
|
}
|
|
78
79
|
}
|
package/src/budget.ts
CHANGED
|
@@ -137,10 +137,10 @@ export function freshPayloadChars(
|
|
|
137
137
|
) * 4,
|
|
138
138
|
);
|
|
139
139
|
}
|
|
140
|
-
export function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
|
|
140
|
+
export function pageCapacity(ctx: PolicyContext, toolTokens: () => number): number {
|
|
141
141
|
const usage = ctx.getContextUsage();
|
|
142
142
|
if (usage === undefined || usage.tokens === null) {
|
|
143
|
-
return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
|
|
143
|
+
return freshPayloadChars(ctx, toolTokens(), [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
|
|
144
144
|
}
|
|
145
145
|
const budget = budgetFor(ctx, usage.contextWindow);
|
|
146
146
|
const line =
|
package/src/files.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
rename,
|
|
22
22
|
stat,
|
|
23
23
|
unlink,
|
|
24
|
+
type FileHandle,
|
|
24
25
|
} from "node:fs/promises";
|
|
25
26
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
26
27
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -123,31 +124,50 @@ export async function publishFile(
|
|
|
123
124
|
throw error;
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
|
-
/**
|
|
127
|
-
export async function*
|
|
127
|
+
/** Bounded LF-delimited segments; end also closes an unterminated final line at EOF. */
|
|
128
|
+
export async function* lfChunks(
|
|
128
129
|
file: string,
|
|
129
130
|
signal?: AbortSignal,
|
|
130
131
|
highWaterMark = 64 * 1024,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
handle?: Readonly<Pick<FileHandle, "createReadStream">>,
|
|
133
|
+
): AsyncGenerator<{ readonly bytes: Buffer; readonly end: boolean }> {
|
|
134
|
+
const stream =
|
|
135
|
+
handle === undefined
|
|
136
|
+
? createReadStream(file, { signal, highWaterMark })
|
|
137
|
+
: handle.createReadStream({ signal, highWaterMark, autoClose: false });
|
|
138
|
+
let openLine = false;
|
|
134
139
|
for await (const chunk of stream) {
|
|
135
140
|
if (!Buffer.isBuffer(chunk)) {
|
|
136
141
|
throw new Error("Expected a byte stream.");
|
|
137
142
|
}
|
|
138
143
|
let start = 0;
|
|
139
144
|
for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
pending = [];
|
|
145
|
+
yield { bytes: chunk.subarray(start, end), end: true };
|
|
146
|
+
openLine = false;
|
|
143
147
|
start = end + 1;
|
|
144
148
|
}
|
|
145
149
|
if (start < chunk.length) {
|
|
146
|
-
|
|
150
|
+
yield { bytes: chunk.subarray(start), end: false };
|
|
151
|
+
openLine = true;
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
|
-
if (
|
|
150
|
-
yield Buffer.
|
|
154
|
+
if (openLine) {
|
|
155
|
+
yield { bytes: Buffer.alloc(0), end: true };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Raw LF-delimited lines; UTF-8 never encodes LF inside another character. */
|
|
159
|
+
export async function* lfLines(
|
|
160
|
+
file: string,
|
|
161
|
+
signal?: AbortSignal,
|
|
162
|
+
highWaterMark = 64 * 1024,
|
|
163
|
+
): AsyncGenerator<Buffer> {
|
|
164
|
+
let pending: Buffer[] = [];
|
|
165
|
+
for await (const { bytes, end } of lfChunks(file, signal, highWaterMark)) {
|
|
166
|
+
pending.push(bytes);
|
|
167
|
+
if (end) {
|
|
168
|
+
yield pending.length === 1 ? bytes : Buffer.concat(pending);
|
|
169
|
+
pending = [];
|
|
170
|
+
}
|
|
151
171
|
}
|
|
152
172
|
}
|
|
153
173
|
/** Git's heuristic: a NUL in the first 8,000 bytes marks binary content. */
|
package/src/history-search.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { relative, resolve } from "node:path";
|
|
2
|
+
import type { LineMatcher } from "./session-lines.ts";
|
|
2
3
|
import {
|
|
3
4
|
isReminderType,
|
|
4
5
|
requireValue,
|
|
@@ -12,7 +13,6 @@ import { shortKey } from "./message-text.ts";
|
|
|
12
13
|
import {
|
|
13
14
|
needleLines,
|
|
14
15
|
prefilterNeedles,
|
|
15
|
-
type RawLine,
|
|
16
16
|
scopedSessionFiles,
|
|
17
17
|
sessionWindowEntries,
|
|
18
18
|
} from "./session-files.ts";
|
|
@@ -82,21 +82,30 @@ function entryMessage(entry: EntryLike) {
|
|
|
82
82
|
return { content: entry.content, summary: entry.summary };
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
function contextVisibility(
|
|
86
|
-
|
|
87
|
-
projected.flatMap(({ sourceEntry, messages }) =>
|
|
88
|
-
sourceEntry.id !== undefined && sourceEntry.id !== "" && messages.length > 0
|
|
89
|
-
? [[sourceEntry.id, messages.map(seenText).join("\n")]]
|
|
90
|
-
: [],
|
|
91
|
-
),
|
|
92
|
-
);
|
|
85
|
+
function contextVisibility(project: HistorySource["projection"]): (entry: EntryLike) => boolean {
|
|
86
|
+
let visible: Map<string, ProjectedEntry["messages"] | string> | undefined;
|
|
93
87
|
return (entry) => {
|
|
94
88
|
if (entry.type === "custom_message" && isReminderType(entry.customType)) {
|
|
95
89
|
return false;
|
|
96
90
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
// Pi owns edits and compaction; only matching candidates need their projected text.
|
|
92
|
+
visible ??= new Map(
|
|
93
|
+
project().flatMap(({ sourceEntry, messages }) =>
|
|
94
|
+
sourceEntry.id !== undefined && sourceEntry.id !== "" && messages.length > 0
|
|
95
|
+
? [[sourceEntry.id, messages]]
|
|
96
|
+
: [],
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
const id = (entry.type === "context_edit" ? entry.targetId : entry.id) ?? "";
|
|
100
|
+
let text = visible.get(id);
|
|
101
|
+
if (text === undefined) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (typeof text !== "string") {
|
|
105
|
+
text = text.map(seenText).join("\n");
|
|
106
|
+
visible.set(id, text);
|
|
107
|
+
}
|
|
108
|
+
return text.includes(seenText(entryMessage(entry)));
|
|
100
109
|
};
|
|
101
110
|
}
|
|
102
111
|
type SearchProgress = {
|
|
@@ -193,14 +202,19 @@ async function scanArchive(
|
|
|
193
202
|
readonly source: string;
|
|
194
203
|
readonly query: string;
|
|
195
204
|
readonly signal?: AbortSignal;
|
|
196
|
-
readonly wanted?:
|
|
205
|
+
readonly wanted?: LineMatcher;
|
|
197
206
|
readonly inContext: (entry: EntryLike) => boolean;
|
|
198
207
|
},
|
|
199
208
|
accumulator: SearchProgress,
|
|
200
209
|
): Promise<{ readonly groups: readonly (readonly HistoryHit[])[]; readonly skipped: number }> {
|
|
201
210
|
const collector = archiveCollector(accumulator);
|
|
202
211
|
let skipped = 0;
|
|
203
|
-
for await (const item of sessionWindowEntries(
|
|
212
|
+
for await (const item of sessionWindowEntries(
|
|
213
|
+
file,
|
|
214
|
+
options.signal,
|
|
215
|
+
undefined,
|
|
216
|
+
options.wanted,
|
|
217
|
+
)) {
|
|
204
218
|
const hit = historyHit(item, options.query, options.source);
|
|
205
219
|
if (hit === undefined) {
|
|
206
220
|
continue;
|
|
@@ -299,7 +313,9 @@ export async function historyMatches(
|
|
|
299
313
|
fail("Invalid history cursor or changed query/scope; restart the search without it.");
|
|
300
314
|
}
|
|
301
315
|
const accumulator = new SearchAccumulator(limit, cursor, fail);
|
|
302
|
-
|
|
316
|
+
// Archive scans await I/O; keep their visibility snapshot at invocation time.
|
|
317
|
+
const projected = params.all === true ? source.projection() : undefined;
|
|
318
|
+
const inContext = contextVisibility(() => projected ?? source.projection());
|
|
303
319
|
const skipped =
|
|
304
320
|
params.all === true
|
|
305
321
|
? await archivedMatches(source, { query, signal, inContext }, accumulator)
|
package/src/history.ts
CHANGED
package/src/notes.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
readdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
realpathSync,
|
|
8
|
-
statSync,
|
|
9
|
-
} from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
10
3
|
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
4
|
+
import { setImmediate } from "node:timers/promises";
|
|
11
5
|
import {
|
|
12
6
|
formatSize,
|
|
13
7
|
withFileMutationQueue,
|
|
@@ -18,6 +12,7 @@ import { Type } from "typebox";
|
|
|
18
12
|
import { toolCards } from "../ui.ts";
|
|
19
13
|
import {
|
|
20
14
|
MAX_PAGE_CHARS,
|
|
15
|
+
isRecord,
|
|
21
16
|
requireValue,
|
|
22
17
|
textResult,
|
|
23
18
|
type PolicyContext,
|
|
@@ -36,10 +31,25 @@ export type NotesParams = {
|
|
|
36
31
|
};
|
|
37
32
|
type NoteFile = { readonly path: string; readonly size: number; readonly mtimeMs: number };
|
|
38
33
|
type NoteHits = { readonly rows: string[]; readonly more: boolean; readonly skipped: number };
|
|
34
|
+
/** Paged operations take a fresh policy snapshot after their filesystem awaits. */
|
|
35
|
+
type NotesAccess = {
|
|
36
|
+
readonly host: () => PolicyContext;
|
|
37
|
+
readonly pages: PageAccess;
|
|
38
|
+
readonly signal: AbortSignal | undefined;
|
|
39
|
+
};
|
|
39
40
|
/** Larger files are logs or evidence rather than notes; reading them would dominate a search. */
|
|
40
41
|
const SEARCH_FILE_BYTES = 1024 * 1024;
|
|
41
42
|
/** Overlapping reads cut a full search of a 212,000-file notes tree from 39 s to 14 s. */
|
|
42
43
|
const SEARCH_BATCH = 16;
|
|
44
|
+
/**
|
|
45
|
+
* Maximum explicit `stat` calls overlapped per batch. On a 212,751-note tree, 16, 64 and 256 took
|
|
46
|
+
* equal time within host noise. The walk enters one folder at a time, so this bounds our pending
|
|
47
|
+
* `stat` calls at any depth. On filesystems that report unknown entry types, Node 24's
|
|
48
|
+
* `readdir({ withFileTypes: true })` also issues its own `lstat` for every entry at once.
|
|
49
|
+
*/
|
|
50
|
+
const WALK_BATCH = 64;
|
|
51
|
+
/** Formatting all 212,751 rows of one listing at once stalled Pi for 156-163 ms. */
|
|
52
|
+
const FORMAT_BATCH = 10_000;
|
|
43
53
|
/** Search rescans up to the requested offset, so this also caps the matches one call keeps. */
|
|
44
54
|
const SEARCH_OFFSET_LIMIT = 1_000_000;
|
|
45
55
|
function safeJoin(dir: string, path: string): string {
|
|
@@ -49,46 +59,105 @@ function safeJoin(dir: string, path: string): string {
|
|
|
49
59
|
}
|
|
50
60
|
return join(dir, relative);
|
|
51
61
|
}
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
/** Like `statSync(path, { throwIfNoEntry: false })`: an entry deleted mid-walk is skipped. */
|
|
63
|
+
async function statIfPresent(path: string) {
|
|
64
|
+
try {
|
|
65
|
+
return await stat(path);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** A folder's present entries in listing order. */
|
|
74
|
+
async function* folderEntries(directory: string, signal: AbortSignal | undefined) {
|
|
75
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
76
|
+
let start = 0;
|
|
77
|
+
while (start < entries.length) {
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
const batch = entries.slice(start, start + WALK_BATCH);
|
|
80
|
+
const firstFolder = batch.findIndex(
|
|
81
|
+
(entry) => entry.isDirectory() || entry.isSymbolicLink(),
|
|
82
|
+
);
|
|
83
|
+
const paths = batch
|
|
84
|
+
.slice(0, firstFolder === -1 ? batch.length : firstFolder + 1)
|
|
85
|
+
.map((entry) => join(directory, entry.name));
|
|
86
|
+
// Stats overlap only up to the next entry that may be a folder.
|
|
87
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
88
|
+
const infos = await Promise.all(paths.map(statIfPresent));
|
|
89
|
+
for (const [index, path] of paths.entries()) {
|
|
90
|
+
start += 1;
|
|
91
|
+
const info = infos[index];
|
|
92
|
+
if (info !== undefined) {
|
|
93
|
+
yield { path, info };
|
|
94
|
+
}
|
|
95
|
+
if (info?.isDirectory() === true) {
|
|
96
|
+
// Stat later entries only after this folder's subtree is walked, discarding any
|
|
97
|
+
// already taken, so entries removed meanwhile are skipped.
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Files depth-first in directory-listing order, yielding between filesystem calls. */
|
|
104
|
+
async function walk(
|
|
105
|
+
directory: string,
|
|
106
|
+
signal: AbortSignal | undefined,
|
|
107
|
+
ancestors: readonly string[] = [],
|
|
108
|
+
): Promise<NoteFile[]> {
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
const root = await realpath(directory);
|
|
54
111
|
if (ancestors.includes(root)) {
|
|
55
112
|
return [];
|
|
56
113
|
}
|
|
57
114
|
const next = [...ancestors, root];
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
const files: NoteFile[] = [];
|
|
116
|
+
for await (const { path, info } of folderEntries(directory, signal)) {
|
|
117
|
+
if (info.isDirectory()) {
|
|
118
|
+
for (const file of await walk(path, signal, next)) {
|
|
119
|
+
files.push(file);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
files.push({ path, size: info.size, mtimeMs: info.mtimeMs });
|
|
63
123
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
: [{ path, size: info.size, mtimeMs: info.mtimeMs }];
|
|
67
|
-
});
|
|
124
|
+
}
|
|
125
|
+
return files;
|
|
68
126
|
}
|
|
69
|
-
function notesUnder(target: string): NoteFile[] {
|
|
70
|
-
const info =
|
|
127
|
+
async function notesUnder(target: string, signal: AbortSignal | undefined): Promise<NoteFile[]> {
|
|
128
|
+
const info = await statIfPresent(target);
|
|
129
|
+
signal?.throwIfAborted();
|
|
71
130
|
if (info === undefined) {
|
|
72
131
|
return [];
|
|
73
132
|
}
|
|
74
133
|
const files = info.isDirectory()
|
|
75
|
-
? walk(target)
|
|
134
|
+
? await walk(target, signal)
|
|
76
135
|
: [{ path: target, size: info.size, mtimeMs: info.mtimeMs }];
|
|
136
|
+
signal?.throwIfAborted();
|
|
77
137
|
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
78
138
|
}
|
|
79
|
-
function listNotes(
|
|
139
|
+
async function listNotes(
|
|
80
140
|
dir: string,
|
|
81
141
|
params: NotesParams,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
142
|
+
access: NotesAccess,
|
|
143
|
+
): Promise<TextResult> {
|
|
144
|
+
const { host, pages, signal } = access;
|
|
85
145
|
const folder = params.path;
|
|
86
146
|
const target = folder === undefined || folder === "" ? dir : safeJoin(dir, folder);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
147
|
+
const files = await notesUnder(target, signal);
|
|
148
|
+
const rows: string[] = [];
|
|
149
|
+
for (let start = 0; start < files.length; start += FORMAT_BATCH) {
|
|
150
|
+
// Pi gets an event-loop turn before each batch of rows.
|
|
151
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
152
|
+
await setImmediate();
|
|
153
|
+
signal?.throwIfAborted();
|
|
154
|
+
for (const file of files.slice(start, start + FORMAT_BATCH)) {
|
|
155
|
+
rows.push(
|
|
156
|
+
`${file.path.slice(dir.length + 1)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return pages.notes(host(), {
|
|
92
161
|
rows,
|
|
93
162
|
offset: params.offset ?? 0,
|
|
94
163
|
kind: "notes-list",
|
|
@@ -99,9 +168,9 @@ function listNotes(
|
|
|
99
168
|
async function readNote(
|
|
100
169
|
dir: string,
|
|
101
170
|
params: NotesParams,
|
|
102
|
-
|
|
103
|
-
pages: PageAccess,
|
|
171
|
+
access: NotesAccess,
|
|
104
172
|
): Promise<TextResult> {
|
|
173
|
+
const { pages } = access;
|
|
105
174
|
const relative = requireValue(params.path, "path", params.op);
|
|
106
175
|
const path = safeJoin(dir, relative);
|
|
107
176
|
const info = statSync(path, { throwIfNoEntry: false });
|
|
@@ -109,7 +178,7 @@ async function readNote(
|
|
|
109
178
|
throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
|
|
110
179
|
}
|
|
111
180
|
if (info.isDirectory()) {
|
|
112
|
-
return listNotes(dir, params,
|
|
181
|
+
return listNotes(dir, params, access);
|
|
113
182
|
}
|
|
114
183
|
if (await isBinaryFile(path)) {
|
|
115
184
|
throw new Error(
|
|
@@ -122,7 +191,7 @@ async function readNote(
|
|
|
122
191
|
throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
|
|
123
192
|
}
|
|
124
193
|
const header = `File: ${path}\n`;
|
|
125
|
-
const host =
|
|
194
|
+
const host = access.host();
|
|
126
195
|
const chars = pages.size(host, { offset }) - header.length;
|
|
127
196
|
if (chars <= 0) {
|
|
128
197
|
pages.error(
|
|
@@ -197,11 +266,13 @@ async function noteHits(
|
|
|
197
266
|
limit: number,
|
|
198
267
|
signal?: AbortSignal,
|
|
199
268
|
): Promise<NoteHits> {
|
|
200
|
-
const notes = notesUnder(dir);
|
|
269
|
+
const notes = await notesUnder(dir, signal);
|
|
201
270
|
const rows: string[] = [];
|
|
202
271
|
let length = -1;
|
|
203
272
|
let skipped = 0;
|
|
204
273
|
for (let start = 0; start < notes.length; start += SEARCH_BATCH) {
|
|
274
|
+
// A batch of notes over 1 MiB reads nothing that would notice cancellation.
|
|
275
|
+
signal?.throwIfAborted();
|
|
205
276
|
// Batches keep note order, so a search stops within one batch of the page limit.
|
|
206
277
|
// oxlint-disable-next-line no-await-in-loop
|
|
207
278
|
const batch = await Promise.all(
|
|
@@ -231,21 +302,20 @@ async function noteHits(
|
|
|
231
302
|
async function searchNotes(
|
|
232
303
|
dir: string,
|
|
233
304
|
params: NotesParams,
|
|
234
|
-
access:
|
|
235
|
-
signal?: AbortSignal,
|
|
305
|
+
access: NotesAccess,
|
|
236
306
|
): Promise<TextResult> {
|
|
237
307
|
const { host, pages } = access;
|
|
238
308
|
const query = requireValue(params.query, "query", params.op);
|
|
239
309
|
const offset = params.offset ?? 0;
|
|
240
310
|
if (offset >= SEARCH_OFFSET_LIMIT) {
|
|
241
311
|
pages.error(
|
|
242
|
-
host,
|
|
312
|
+
host(),
|
|
243
313
|
`Notes search pages end before offset ${SEARCH_OFFSET_LIMIT}; search for something more specific.`,
|
|
244
314
|
);
|
|
245
315
|
}
|
|
246
|
-
const hits = await noteHits(dir, query.toLowerCase(), offset + MAX_PAGE_CHARS, signal);
|
|
316
|
+
const hits = await noteHits(dir, query.toLowerCase(), offset + MAX_PAGE_CHARS, access.signal);
|
|
247
317
|
const skipped = hits.skipped === 1 ? "1 note" : `${hits.skipped} notes`;
|
|
248
|
-
return pages.notes(host, {
|
|
318
|
+
return pages.notes(host(), {
|
|
249
319
|
rows: hits.rows,
|
|
250
320
|
more: hits.more,
|
|
251
321
|
offset,
|
|
@@ -297,17 +367,18 @@ export function registerNotes(
|
|
|
297
367
|
// oxlint-disable-next-line max-params
|
|
298
368
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
299
369
|
const dir = notesDir(ctx.cwd);
|
|
370
|
+
const access: NotesAccess = { host: () => policy(ctx), pages, signal };
|
|
300
371
|
switch (params.op) {
|
|
301
372
|
case "list":
|
|
302
|
-
return listNotes(dir, params,
|
|
373
|
+
return listNotes(dir, params, access);
|
|
303
374
|
case "read":
|
|
304
|
-
return readNote(dir, params,
|
|
375
|
+
return readNote(dir, params, access);
|
|
305
376
|
case "write":
|
|
306
377
|
return writeNote(dir, params, signal);
|
|
307
378
|
case "append":
|
|
308
379
|
return appendNote(dir, params, signal);
|
|
309
380
|
case "search":
|
|
310
|
-
return searchNotes(dir, params,
|
|
381
|
+
return searchNotes(dir, params, access);
|
|
311
382
|
}
|
|
312
383
|
},
|
|
313
384
|
});
|
package/src/paging.ts
CHANGED
|
@@ -63,7 +63,7 @@ export class PageReservations {
|
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
65
|
this.previousUsage = usage;
|
|
66
|
-
const remaining = pageCapacity(ctx, this.toolTokens
|
|
66
|
+
const remaining = pageCapacity(ctx, this.toolTokens) - this.pendingTokens;
|
|
67
67
|
const chars = Math.min(
|
|
68
68
|
MAX_PAGE_CHARS,
|
|
69
69
|
Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 -
|
package/src/recovery-results.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
toolIdentity,
|
|
10
10
|
} from "./message-text.ts";
|
|
11
11
|
|
|
12
|
-
export type RecoveryEntry = EntryLike & { readonly
|
|
12
|
+
export type RecoveryEntry = EntryLike & { readonly retained: boolean };
|
|
13
13
|
type LinkedCall = { readonly entry: EntryLike; readonly block: ToolCallLike };
|
|
14
14
|
export type RecoveryBlock = { readonly header: string; readonly text: string };
|
|
15
15
|
export type RecoveryBatch = { readonly callId?: string; readonly blocks: readonly RecoveryBlock[] };
|
|
@@ -85,17 +85,12 @@ function resultBlock(
|
|
|
85
85
|
/** Async receipts may arrive after a response; completion does not establish receipt delivery. */
|
|
86
86
|
export function recoverableToolResults(
|
|
87
87
|
entries: readonly RecoveryEntry[],
|
|
88
|
-
retained: ReadonlySet<string>,
|
|
89
88
|
): RecoveryBatch | undefined {
|
|
90
89
|
const calls = callsById(entries);
|
|
91
90
|
const start = trailingStart(entries);
|
|
92
91
|
const results = entries.filter((entry, index) => {
|
|
93
92
|
const message = entry.message;
|
|
94
|
-
if (
|
|
95
|
-
message?.role !== "toolResult" ||
|
|
96
|
-
message.toolCallId === undefined ||
|
|
97
|
-
retained.has(entry.sourceEntryId ?? "")
|
|
98
|
-
) {
|
|
93
|
+
if (message?.role !== "toolResult" || message.toolCallId === undefined || entry.retained) {
|
|
99
94
|
return false;
|
|
100
95
|
}
|
|
101
96
|
const call = calls.get(message.toolCallId);
|
package/src/recovery.ts
CHANGED
|
@@ -265,6 +265,7 @@ function skippedRecords(ids: readonly string[], limit: number): string | undefin
|
|
|
265
265
|
function projectedToolEntries(
|
|
266
266
|
entries: readonly EntryLike[],
|
|
267
267
|
projected: readonly ProjectedEntry[],
|
|
268
|
+
retained: ReadonlySet<string>,
|
|
268
269
|
): RecoveryEntry[] {
|
|
269
270
|
const edits = contextEdits(entries);
|
|
270
271
|
return projected.flatMap(({ sourceEntry, messages }) =>
|
|
@@ -272,8 +273,8 @@ function projectedToolEntries(
|
|
|
272
273
|
.filter((message) => message.role === "assistant" || message.role === "toolResult")
|
|
273
274
|
.map((message) => ({
|
|
274
275
|
type: "message",
|
|
275
|
-
//
|
|
276
|
-
|
|
276
|
+
// Resolve retention before replacing identity with the history-read reference.
|
|
277
|
+
retained: retained.has(sourceEntry.id ?? ""),
|
|
277
278
|
id: edits.get(sourceEntry.id ?? "")?.id ?? sourceEntry.id,
|
|
278
279
|
message,
|
|
279
280
|
})),
|
|
@@ -442,7 +443,7 @@ export function buildAutoHandoff(
|
|
|
442
443
|
fixed.length > 0
|
|
443
444
|
? "Selected owner and coordination inputs (chronological):"
|
|
444
445
|
: emptyInputHeader;
|
|
445
|
-
const batch = recoverableToolResults(projectedToolEntries(entries, projected
|
|
446
|
+
const batch = recoverableToolResults(projectedToolEntries(entries, projected, retained));
|
|
446
447
|
const header = batchHeader(batch);
|
|
447
448
|
const minimum = [
|
|
448
449
|
preamble,
|
package/src/session-files.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type WindowedEntry,
|
|
13
13
|
} from "./contracts.ts";
|
|
14
14
|
import { lfLines } from "./files.ts";
|
|
15
|
+
import { sessionLines, type LineMatcher, type SessionLine } from "./session-lines.ts";
|
|
15
16
|
import { toWindowedEntry, windowProjection } from "./history-entries.ts";
|
|
16
17
|
import { shortKey } from "./message-text.ts";
|
|
17
18
|
|
|
@@ -78,11 +79,11 @@ const ASCII_FOLDS: Readonly<Partial<Record<string, string>>> = {
|
|
|
78
79
|
k: "\u00e2\u0084\u00aa",
|
|
79
80
|
s: "\u00c5\u00bf",
|
|
80
81
|
};
|
|
81
|
-
const DOTTED_I = Buffer.from("\u0130");
|
|
82
|
-
/**
|
|
83
|
-
export
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
const DOTTED_I = Buffer.from("\u0130").toString("latin1");
|
|
83
|
+
/** Raw-line matcher for prefilterNeedles, ignoring case like the rendered text would. */
|
|
84
|
+
export function needleLines(needles: readonly string[]): LineMatcher {
|
|
85
|
+
const overlap =
|
|
86
|
+
4 * needles.reduce((longest, needle) => Math.max(longest, needle.length), 0) + 4;
|
|
86
87
|
const others = needles.filter((needle) => needle !== "\u0130");
|
|
87
88
|
const dotted = others.length < needles.length;
|
|
88
89
|
if (others.every((needle) => /^[\0-\x7f]*$/u.test(needle))) {
|
|
@@ -97,11 +98,14 @@ export function needleLines(needles: readonly string[]): (bytes: RawLine) => boo
|
|
|
97
98
|
.join(""),
|
|
98
99
|
);
|
|
99
100
|
const pattern = new RegExp(latin1.join("|"), "i");
|
|
100
|
-
return
|
|
101
|
-
(dotted &&
|
|
101
|
+
return {
|
|
102
|
+
test: (text) => (dotted && text.includes(DOTTED_I)) || pattern.test(text),
|
|
103
|
+
encoding: "latin1",
|
|
104
|
+
overlap,
|
|
105
|
+
};
|
|
102
106
|
}
|
|
103
107
|
const pattern = new RegExp(needles.map((needle) => RegExp.escape(needle)).join("|"), "iu");
|
|
104
|
-
return (
|
|
108
|
+
return { test: (text) => pattern.test(text), encoding: "utf8", overlap };
|
|
105
109
|
}
|
|
106
110
|
/** File lists that each fit one ripgrep command line. */
|
|
107
111
|
function commandGroups(paths: readonly string[]): string[][] {
|
|
@@ -210,8 +214,8 @@ function parsedEntry(line: string): EntryLike | undefined {
|
|
|
210
214
|
} // Pi skips malformed journal lines too.
|
|
211
215
|
}
|
|
212
216
|
/** Window metadata of a Pi-ordered line that cannot open a window, without parsing it. */
|
|
213
|
-
function entryHead(
|
|
214
|
-
const head = ENTRY_HEAD.exec(
|
|
217
|
+
function entryHead(prefix: string): EntryLike | undefined {
|
|
218
|
+
const head = ENTRY_HEAD.exec(prefix);
|
|
215
219
|
if (head === null || head[1] === "compaction" || head[1] === "context_window") {
|
|
216
220
|
return undefined;
|
|
217
221
|
}
|
|
@@ -222,31 +226,40 @@ function entryHead(bytes: RawLine): EntryLike | undefined {
|
|
|
222
226
|
};
|
|
223
227
|
}
|
|
224
228
|
/** Whether a caller may need a line; other lines only advance window ancestry. */
|
|
225
|
-
type LineFilter = (
|
|
229
|
+
type LineFilter = (entry: EntryLike) => boolean;
|
|
230
|
+
async function readEntry(
|
|
231
|
+
line: SessionLine,
|
|
232
|
+
wanted: LineFilter,
|
|
233
|
+
): Promise<{ readonly entry: EntryLike; readonly selected: boolean } | undefined> {
|
|
234
|
+
const head = entryHead(line.head);
|
|
235
|
+
if (head !== undefined && (!line.matches || !wanted(head))) {
|
|
236
|
+
return { entry: head, selected: false };
|
|
237
|
+
}
|
|
238
|
+
const entry = parsedEntry(await line.text());
|
|
239
|
+
return entry === undefined
|
|
240
|
+
? undefined
|
|
241
|
+
: {
|
|
242
|
+
entry,
|
|
243
|
+
selected: head !== undefined || (line.matches && wanted(entry)),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
226
246
|
export async function* sessionWindowEntries(
|
|
227
247
|
file: string,
|
|
228
248
|
signal?: AbortSignal,
|
|
229
249
|
wanted: LineFilter = () => true,
|
|
250
|
+
matcher?: LineMatcher,
|
|
230
251
|
): AsyncGenerator<WindowedEntry> {
|
|
231
252
|
if (!existsSync(file)) {
|
|
232
253
|
return;
|
|
233
254
|
}
|
|
234
255
|
const project = windowProjection();
|
|
235
|
-
for await (const
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
project(head);
|
|
256
|
+
for await (const line of sessionLines(file, signal, matcher)) {
|
|
257
|
+
const candidate = await readEntry(line, wanted);
|
|
258
|
+
if (candidate === undefined) {
|
|
239
259
|
continue;
|
|
240
260
|
}
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
const windowId = project(entry);
|
|
246
|
-
const item =
|
|
247
|
-
head !== undefined || wanted(bytes, entry)
|
|
248
|
-
? toWindowedEntry(entry, windowId)
|
|
249
|
-
: undefined;
|
|
261
|
+
const windowId = project(candidate.entry);
|
|
262
|
+
const item = candidate.selected ? toWindowedEntry(candidate.entry, windowId) : undefined;
|
|
250
263
|
if (item !== undefined) {
|
|
251
264
|
yield item;
|
|
252
265
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { open, type FileHandle } from "node:fs/promises";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { lfChunks } from "./files.ts";
|
|
4
|
+
|
|
5
|
+
export type LineMatcher = {
|
|
6
|
+
readonly test: (text: string) => boolean;
|
|
7
|
+
readonly encoding: "latin1" | "utf8";
|
|
8
|
+
/** Enough characters to include every case-fold alternative of any needle. */
|
|
9
|
+
readonly overlap: number;
|
|
10
|
+
};
|
|
11
|
+
export type SessionLine = {
|
|
12
|
+
readonly head: string;
|
|
13
|
+
readonly matches: boolean;
|
|
14
|
+
readonly text: () => Promise<string>;
|
|
15
|
+
};
|
|
16
|
+
type LineRange = {
|
|
17
|
+
readonly start: number;
|
|
18
|
+
readonly end: number;
|
|
19
|
+
readonly head: string;
|
|
20
|
+
readonly matches: boolean;
|
|
21
|
+
readonly bytes?: Readonly<Pick<Buffer, "toString">>;
|
|
22
|
+
};
|
|
23
|
+
async function* lineRanges(
|
|
24
|
+
file: string,
|
|
25
|
+
handle: Readonly<Pick<FileHandle, "createReadStream">>,
|
|
26
|
+
signal: AbortSignal | undefined,
|
|
27
|
+
matcher: LineMatcher | undefined,
|
|
28
|
+
): AsyncGenerator<LineRange> {
|
|
29
|
+
let start = 0;
|
|
30
|
+
let position = 0;
|
|
31
|
+
let head = Buffer.alloc(0);
|
|
32
|
+
let tail = "";
|
|
33
|
+
let decoder = new StringDecoder(matcher?.encoding);
|
|
34
|
+
let matches = matcher === undefined;
|
|
35
|
+
for await (const { bytes, end } of lfChunks(file, signal, 1024 * 1024, handle)) {
|
|
36
|
+
if (head.length < 512) {
|
|
37
|
+
head = Buffer.concat([head, bytes.subarray(0, 512 - head.length)]);
|
|
38
|
+
}
|
|
39
|
+
if (!matches && matcher !== undefined) {
|
|
40
|
+
const window = tail + decoder.write(bytes) + (end ? decoder.end() : "");
|
|
41
|
+
matches = matcher.test(window);
|
|
42
|
+
tail = window.slice(-matcher.overlap);
|
|
43
|
+
}
|
|
44
|
+
position += bytes.length;
|
|
45
|
+
if (end) {
|
|
46
|
+
yield {
|
|
47
|
+
start,
|
|
48
|
+
end: position,
|
|
49
|
+
head: head.toString("utf8"),
|
|
50
|
+
matches,
|
|
51
|
+
bytes: position - start === bytes.length ? bytes : undefined,
|
|
52
|
+
};
|
|
53
|
+
position++;
|
|
54
|
+
start = position;
|
|
55
|
+
head = Buffer.alloc(0);
|
|
56
|
+
tail = "";
|
|
57
|
+
decoder = new StringDecoder(matcher?.encoding);
|
|
58
|
+
matches = matcher === undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function rangeText(
|
|
63
|
+
handle: Readonly<Pick<FileHandle, "read">>,
|
|
64
|
+
line: LineRange,
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
if (line.bytes !== undefined) {
|
|
69
|
+
return line.bytes.toString("utf8");
|
|
70
|
+
}
|
|
71
|
+
const buffer = Buffer.alloc(line.end - line.start);
|
|
72
|
+
let offset = 0;
|
|
73
|
+
while (offset < buffer.length) {
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
// Positioned reads may be short; each continues the same selected record.
|
|
76
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
77
|
+
const { bytesRead } = await handle.read(
|
|
78
|
+
buffer,
|
|
79
|
+
offset,
|
|
80
|
+
buffer.length - offset,
|
|
81
|
+
line.start + offset,
|
|
82
|
+
);
|
|
83
|
+
if (bytesRead === 0) {
|
|
84
|
+
throw new Error("Session file changed while reading a history entry.");
|
|
85
|
+
}
|
|
86
|
+
offset += bytesRead;
|
|
87
|
+
}
|
|
88
|
+
return buffer.toString("utf8");
|
|
89
|
+
}
|
|
90
|
+
/** Irrelevant lines retain only their prefix, byte range and needle overlap, not their payload. */
|
|
91
|
+
export async function* sessionLines(
|
|
92
|
+
file: string,
|
|
93
|
+
signal?: AbortSignal,
|
|
94
|
+
matcher?: LineMatcher,
|
|
95
|
+
): AsyncGenerator<SessionLine> {
|
|
96
|
+
const handle = await open(file, "r");
|
|
97
|
+
try {
|
|
98
|
+
for await (const line of lineRanges(file, handle, signal, matcher)) {
|
|
99
|
+
yield {
|
|
100
|
+
head: line.head,
|
|
101
|
+
matches: line.matches,
|
|
102
|
+
text: () => rangeText(handle, line, signal),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
}
|