flashlite 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ // Bounded regular-file reads and literal search. No whole-file/whole-line buffering.
2
+ import fs from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { isUtf8 } from 'node:buffer';
5
+ const CHUNK_BYTES = 64 * 1024;
6
+ export const MAX_MATCH_SNIPPET_BYTES = 512;
7
+ export function secretPadding(values) {
8
+ const padding = Math.max(4, ...values.map((value) => Buffer.byteLength(value)));
9
+ if (padding > 16384)
10
+ throw new Error('protected credential exceeds bounded text context limit; use file_info instead');
11
+ return padding;
12
+ }
13
+ // Redact intersections, including a credential split across page/snippet boundaries.
14
+ export function redactedSlice(bytes, start, end, values) {
15
+ const ranges = [];
16
+ for (const value of values) {
17
+ if (!value)
18
+ continue;
19
+ const needle = Buffer.from(value);
20
+ for (let at = bytes.indexOf(needle); at !== -1; at = bytes.indexOf(needle, at + 1)) {
21
+ if (at < end && at + needle.length > start)
22
+ ranges.push([Math.max(at, start), Math.min(at + needle.length, end)]);
23
+ }
24
+ }
25
+ ranges.sort((a, b) => a[0] - b[0]);
26
+ let at = start, result = '';
27
+ for (const [left, right] of ranges) {
28
+ if (right <= at)
29
+ continue;
30
+ if (left > at)
31
+ result += bytes.subarray(at, left).toString('utf8');
32
+ result += '[REDACTED]';
33
+ at = right;
34
+ }
35
+ return result + bytes.subarray(at, end).toString('utf8');
36
+ }
37
+ const continuation = (byte) => byte !== undefined && (byte & 0xc0) === 0x80;
38
+ async function regularFile(abs, follow, check) {
39
+ check();
40
+ // Text reads may resolve an explicitly requested symlink; tree search never does.
41
+ const target = follow ? await fs.realpath(abs) : abs;
42
+ const file = await fs.open(target, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
43
+ try {
44
+ check();
45
+ const stat = await file.stat();
46
+ if (!stat.isFile())
47
+ throw new Error('not a regular file — use list_dir or file_info');
48
+ return { file, stat };
49
+ }
50
+ catch (err) {
51
+ await file.close();
52
+ throw err;
53
+ }
54
+ }
55
+ export async function readPage(abs, offset, limit, check, values) {
56
+ const { file, stat } = await regularFile(abs, true, check);
57
+ try {
58
+ if (offset > stat.size)
59
+ throw new Error(`offset_bytes exceeds file size ${stat.size}; retry offset_bytes: 0 or use file_info`);
60
+ const padding = secretPadding(values);
61
+ const base = Math.max(0, offset - padding);
62
+ const bytes = Buffer.alloc(limit + 2 * padding + 4);
63
+ let used = 0;
64
+ while (used < bytes.length) {
65
+ check();
66
+ const read = await file.read(bytes, used, bytes.length - used, base + used);
67
+ check();
68
+ if (!read.bytesRead)
69
+ break;
70
+ used += read.bytesRead;
71
+ }
72
+ const buffer = bytes.subarray(0, used);
73
+ const start = offset - base;
74
+ if (continuation(buffer[start])) {
75
+ let previous = start;
76
+ while (previous > 0 && continuation(buffer[previous]))
77
+ previous--;
78
+ throw new Error(`offset_bytes splits a UTF-8 character; retry offset_bytes: ${base + previous}, or use next_offset_bytes from the previous page`);
79
+ }
80
+ let end = Math.min(start + limit, used);
81
+ while (end > start && continuation(buffer[end]))
82
+ end--;
83
+ if (end === start && start < used)
84
+ throw new Error('max_bytes is too small for the next UTF-8 character; retry max_bytes: 4 or larger');
85
+ const page = buffer.subarray(start, end);
86
+ if (page.includes(0))
87
+ throw new Error('binary file — use file_info or copy_path');
88
+ if (!isUtf8(page))
89
+ throw new Error('file is not valid UTF-8 text — use file_info or copy_path');
90
+ check();
91
+ if ((await file.stat()).size !== stat.size)
92
+ throw new Error('file changed during read; use file_info then retry offset_bytes: 0');
93
+ check();
94
+ const next = offset + page.length;
95
+ return { offset_bytes: offset, end_offset_bytes: next, total_bytes: stat.size,
96
+ next_offset_bytes: next < stat.size ? next : null,
97
+ content: redactedSlice(buffer, start, end, values) };
98
+ }
99
+ finally {
100
+ await file.close();
101
+ }
102
+ }
103
+ /** Search occurrences, retaining only a chunk, literal overlap and snippet context.
104
+ * start > 0 deliberately has unknown line numbers; exact byte positions remain valid.
105
+ */
106
+ export async function searchFile(abs, pattern, start, budget, check, values, onMatch) {
107
+ const { file, stat } = await regularFile(abs, false, check);
108
+ try {
109
+ if (start > stat.size)
110
+ throw new Error(`start_byte exceeds file size ${stat.size}; retry start_byte: 0`);
111
+ const padding = secretPadding(values);
112
+ // A cropped snippet can start/end up to its full width from a match. Keep
113
+ // credential-length padding beyond BOTH snippet edges, not merely the match.
114
+ const context = MAX_MATCH_SNIPPET_BYTES + padding;
115
+ const baseStart = Math.max(0, start - context);
116
+ let buffer = Buffer.alloc(start - baseStart);
117
+ if (buffer.length) {
118
+ check();
119
+ const prefix = await file.read(buffer, 0, buffer.length, baseStart);
120
+ check();
121
+ if (prefix.bytesRead !== buffer.length)
122
+ throw new Error('file changed during search; use file_info then retry start_byte: 0');
123
+ }
124
+ let base = baseStart, readAt = start, scan = start - baseStart, line = start === 0 ? 1 : null;
125
+ let bytesRead = 0;
126
+ const chunk = Buffer.alloc(CHUNK_BYTES);
127
+ while (true) {
128
+ check();
129
+ const remaining = budget - bytesRead;
130
+ const { bytesRead: count } = remaining > 0 ? await file.read(chunk, 0, Math.min(chunk.length, remaining), readAt) : { bytesRead: 0 };
131
+ check();
132
+ if (count) {
133
+ const fresh = chunk.subarray(0, count);
134
+ bytesRead += count;
135
+ readAt += count;
136
+ if (fresh.includes(0))
137
+ return { bytesRead, nextByte: null, reason: 'binary file skipped (any earlier matches cover text prefix only)' };
138
+ buffer = Buffer.concat([buffer, fresh]);
139
+ }
140
+ const end = !count || readAt >= stat.size;
141
+ const searchedEnd = buffer.length;
142
+ if (end && readAt < stat.size && values.some((value) => value.length > 0)) {
143
+ // At the scan cap the final snippet may end inside a known credential.
144
+ // Inspect only bounded suffix context to redact that intersection. These
145
+ // bytes are NOT searched, emitted, counted as scanned, or consumed by the
146
+ // continuation offset. Snippet bounds and matching remain at searchedEnd.
147
+ const suffix = Buffer.alloc(padding);
148
+ let used = 0;
149
+ while (used < suffix.length) {
150
+ check();
151
+ const lookahead = await file.read(suffix, used, suffix.length - used, readAt + used);
152
+ check();
153
+ if (!lookahead.bytesRead)
154
+ break;
155
+ used += lookahead.bytesRead;
156
+ }
157
+ buffer = Buffer.concat([buffer, suffix.subarray(0, used)]);
158
+ }
159
+ // Do not finalize a match until enough suffix exists for a bounded snippet/redaction.
160
+ const ready = end ? (readAt >= stat.size ? searchedEnd : Math.max(scan, searchedEnd - pattern.length + 1)) :
161
+ Math.max(scan, searchedEnd - pattern.length - context);
162
+ while (scan < ready) {
163
+ check();
164
+ const found = buffer.indexOf(pattern, scan);
165
+ const stop = found < 0 || found >= ready ? ready : found;
166
+ if (line !== null)
167
+ for (let i = scan; i < stop; i++)
168
+ if (buffer[i] === 10)
169
+ line++;
170
+ scan = stop;
171
+ if (found < 0 || found >= ready)
172
+ break;
173
+ const previousNL = buffer.lastIndexOf(10, found);
174
+ const followingNL = buffer.indexOf(10, found + pattern.length);
175
+ const nextNL = followingNL >= searchedEnd ? -1 : followingNL;
176
+ let left = Math.max(previousNL + 1, found - 192);
177
+ let right = Math.min(nextNL < 0 ? searchedEnd : nextNL, left + MAX_MATCH_SNIPPET_BYTES);
178
+ // UTF-8 boundary adjustment; pattern longer than snippet is intentionally cropped.
179
+ while (left < found && continuation(buffer[left]))
180
+ left++;
181
+ while (right > left && continuation(buffer[right]))
182
+ right--;
183
+ const snippet = left > previousNL + 1 || nextNL < 0 && readAt < stat.size || right < (nextNL < 0 ? searchedEnd : nextNL) || base + left > start && previousNL < 0;
184
+ const content = redactedSlice(buffer, left, right, values).trim();
185
+ if (onMatch({ byte: base + found, line, content, snippet })) {
186
+ // The occurrence was NOT accepted. Retrying this byte will not lose it.
187
+ return { bytesRead, nextByte: base + found, reason: 'result/output capped' };
188
+ }
189
+ // Non-overlapping occurrences; unlike exact edit, grep need not enumerate overlap.
190
+ const advance = found + pattern.length;
191
+ if (line !== null)
192
+ for (let i = scan; i < advance; i++)
193
+ if (buffer[i] === 10)
194
+ line++;
195
+ scan = advance;
196
+ }
197
+ if (end) {
198
+ check();
199
+ if ((await file.stat()).size !== stat.size)
200
+ throw new Error('file changed during search; use file_info then retry start_byte: 0');
201
+ check();
202
+ return readAt >= stat.size ? { bytesRead, nextByte: null } :
203
+ { bytesRead, nextByte: base + scan, reason: 'scan byte budget reached' };
204
+ }
205
+ const drop = Math.max(0, scan - context);
206
+ buffer = Buffer.from(buffer.subarray(drop));
207
+ base += drop;
208
+ scan -= drop;
209
+ }
210
+ }
211
+ finally {
212
+ await file.close();
213
+ }
214
+ }
@@ -0,0 +1,123 @@
1
+ // A bounded display buffer for app-managed scrollback; the composer is not part of it.
2
+ import { stripVTControlCharacters } from 'node:util';
3
+ function cells(c) {
4
+ if (/\p{Mark}/u.test(c))
5
+ return 0;
6
+ const cp = c.codePointAt(0);
7
+ return cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf ||
8
+ cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
9
+ cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff00 && cp <= 0xff60 || cp >= 0x1f300) ? 2 : 1;
10
+ }
11
+ export class ChatViewport {
12
+ lines = [''];
13
+ anchors = new Set();
14
+ offset = 0;
15
+ clear() { this.lines = ['']; this.anchors.clear(); this.offset = 0; }
16
+ position() {
17
+ const position = { line: this.lines.length - 1, length: this.lines.at(-1).length };
18
+ this.anchors.add(position);
19
+ return position;
20
+ }
21
+ release(...positions) {
22
+ for (const position of positions)
23
+ this.anchors.delete(position);
24
+ }
25
+ trim() {
26
+ if (this.lines.length <= 10_000)
27
+ return;
28
+ const removed = this.lines.length - 10_000;
29
+ this.lines.splice(0, removed);
30
+ for (const anchor of this.anchors)
31
+ anchor.line = Math.max(0, anchor.line - removed);
32
+ }
33
+ replace(start, end, text, columns) {
34
+ const before = this.offset ? this.rows(columns).length : 0;
35
+ const oldEndLine = end.line;
36
+ const oldEndLength = end.length;
37
+ const prefix = this.lines[start.line]?.slice(0, start.length) ?? '';
38
+ const suffix = this.lines[end.line]?.slice(end.length) ?? '';
39
+ const inserted = (prefix + text).split('\n');
40
+ const newEndLine = start.line + inserted.length - 1;
41
+ const newEndLength = inserted.at(-1).length;
42
+ inserted[inserted.length - 1] += suffix;
43
+ this.lines.splice(start.line, oldEndLine - start.line + 1, ...inserted);
44
+ const delta = newEndLine - oldEndLine;
45
+ for (const anchor of this.anchors) {
46
+ if (anchor === start)
47
+ continue;
48
+ if (anchor === end) {
49
+ anchor.line = newEndLine;
50
+ anchor.length = newEndLength;
51
+ }
52
+ else if (anchor.line > oldEndLine)
53
+ anchor.line += delta;
54
+ else if (anchor.line === oldEndLine && anchor.length >= oldEndLength) {
55
+ anchor.line = newEndLine;
56
+ anchor.length = newEndLength + anchor.length - oldEndLength;
57
+ }
58
+ }
59
+ this.trim();
60
+ if (this.offset)
61
+ this.offset = Math.max(0, this.offset + this.rows(columns).length - before);
62
+ }
63
+ remove(start, end) {
64
+ this.replace(start, end, '', 80);
65
+ this.offset = 0;
66
+ }
67
+ append(text, columns) {
68
+ const before = this.offset ? this.rows(columns).length : 0;
69
+ for (const part of text.split(/(\n|\r\x1b\[K)/)) {
70
+ if (part === '\n')
71
+ this.lines.push('');
72
+ else if (part === '\r\x1b[K')
73
+ this.lines[this.lines.length - 1] = '';
74
+ else
75
+ this.lines[this.lines.length - 1] += part;
76
+ }
77
+ this.trim();
78
+ if (this.offset)
79
+ this.offset = Math.max(0, this.offset + this.rows(columns).length - before);
80
+ }
81
+ rows(columns) {
82
+ const rows = [];
83
+ columns = Math.max(1, columns);
84
+ let style = '';
85
+ for (const line of this.lines) {
86
+ let row = style, used = 0;
87
+ for (const token of line.match(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b.|[^\x1b]/gu) ?? []) {
88
+ if (token.startsWith('\x1b')) {
89
+ if (/^\x1b\[[\d;]*m$/.test(token)) {
90
+ row += token;
91
+ style = /^\x1b\[(0)?m$/.test(token) ? '' : style + token;
92
+ }
93
+ continue;
94
+ }
95
+ if (token === '\r' || token < ' ')
96
+ continue;
97
+ if (used + cells(token) > columns) {
98
+ rows.push(row + (style ? '\x1b[0m' : ''));
99
+ row = style;
100
+ used = 0;
101
+ }
102
+ row += token;
103
+ used += cells(token);
104
+ }
105
+ rows.push(row + (style ? '\x1b[0m' : ''));
106
+ }
107
+ return rows;
108
+ }
109
+ scroll(delta, columns, height) {
110
+ this.offset = Math.max(0, Math.min(Math.max(0, this.rows(columns).length - height), this.offset + delta));
111
+ }
112
+ visible(columns, height) {
113
+ const rows = this.rows(columns);
114
+ const end = Math.max(height, rows.length - this.offset);
115
+ this.offset = Math.min(this.offset, Math.max(0, rows.length - height));
116
+ return rows.slice(Math.max(0, end - height), end);
117
+ }
118
+ caret(columns, height) {
119
+ const rows = this.rows(columns);
120
+ const last = stripVTControlCharacters(rows.at(-1) ?? '');
121
+ return { row: Math.min(height, rows.length), column: Math.min(columns + 1, [...last].reduce((sum, c) => sum + cells(c), 0) + 1) };
122
+ }
123
+ }
@@ -0,0 +1,100 @@
1
+ // Git-style undo for file-tool mutations. Before a mutating file tool runs, the target
2
+ // path's original bytes (or absence) are captured; `/undo` restores the most recent turn's
3
+ // captured files. run_command side effects are never captured (arbitrary shell is not
4
+ // reversible this way). State is in-memory per REPL session — undo works until you exit.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ const MAX_CHECKPOINTS = 32;
8
+ const MAX_FILE_BYTES = 8 * 1024 * 1024; // refuse to buffer very large files
9
+ /** Mutating file tools whose target(s) we snapshot before they run. */
10
+ const CAPTURE_PATHS = {
11
+ write_file: (args) => [asPath(args.path)],
12
+ edit_file: (args) => [asPath(args.path)],
13
+ make_dir: (args) => [asPath(args.path)],
14
+ remove_path: (args) => [asPath(args.path)],
15
+ copy_path: (args) => [asPath(args.destination)],
16
+ move_path: (args) => [asPath(args.source), asPath(args.destination)],
17
+ };
18
+ function asPath(value) {
19
+ return typeof value === 'string' ? value : '';
20
+ }
21
+ export class UndoStack {
22
+ turn = null;
23
+ stack = [];
24
+ /** Start a fresh capture set for a new turn. */
25
+ beginTurn() {
26
+ this.turn = null;
27
+ }
28
+ /** Capture a tool's target paths before execution (absolute, resolved against cwd). */
29
+ capture(cwd, name, args) {
30
+ const extract = CAPTURE_PATHS[name];
31
+ if (!extract)
32
+ return;
33
+ const paths = extract(args).filter(Boolean).map((p) => path.resolve(cwd, p));
34
+ if (paths.length === 0)
35
+ return;
36
+ if (!this.turn)
37
+ this.turn = { files: [], count: 0 };
38
+ for (const target of paths) {
39
+ if (this.turn.files.some((f) => f.path === target))
40
+ continue; // already captured this turn
41
+ let stat;
42
+ try {
43
+ stat = fs.lstatSync(target);
44
+ }
45
+ catch { /* absent */ }
46
+ const existed = !!stat && (stat.isFile() || stat.isSymbolicLink());
47
+ const entry = { path: target, existed };
48
+ if (existed && stat.size <= MAX_FILE_BYTES) {
49
+ try {
50
+ entry.content = fs.readFileSync(target);
51
+ }
52
+ catch {
53
+ entry.content = undefined;
54
+ }
55
+ }
56
+ this.turn.files.push(entry);
57
+ this.turn.count++;
58
+ }
59
+ }
60
+ /** Seal the turn's captures; a turn with no file mutations records nothing. */
61
+ endTurn() {
62
+ if (this.turn && this.turn.files.length > 0) {
63
+ this.stack.push(this.turn);
64
+ if (this.stack.length > MAX_CHECKPOINTS)
65
+ this.stack.shift();
66
+ }
67
+ this.turn = null;
68
+ }
69
+ hasUndo() {
70
+ return this.stack.length > 0;
71
+ }
72
+ /** Restore the most recent turn's captured files; returns human-readable summary lines. */
73
+ undo(cwd) {
74
+ const checkpoint = this.stack.pop();
75
+ if (!checkpoint || checkpoint.files.length === 0)
76
+ return ['nothing to undo'];
77
+ const lines = [];
78
+ for (const file of [...checkpoint.files].reverse()) {
79
+ const rel = path.relative(cwd, file.path) || file.path;
80
+ try {
81
+ if (file.existed && file.content !== undefined) {
82
+ fs.mkdirSync(path.dirname(file.path), { recursive: true });
83
+ fs.writeFileSync(file.path, file.content);
84
+ lines.push(`restored ${rel}`);
85
+ }
86
+ else if (!file.existed) {
87
+ fs.rmSync(file.path, { force: true });
88
+ lines.push(`removed ${rel}`);
89
+ }
90
+ else {
91
+ lines.push(`kept ${rel} (original not captured — file too large)`);
92
+ }
93
+ }
94
+ catch (err) {
95
+ lines.push(`could not restore ${rel}: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
97
+ }
98
+ return lines;
99
+ }
100
+ }