@oh-my-pi/pi-coding-agent 15.5.10 → 15.5.11
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/CHANGELOG.md +21 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +14 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/ultrathink.d.ts +10 -0
- package/dist/types/session/redis-session-storage.d.ts +124 -0
- package/dist/types/session/sql-session-storage.d.ts +141 -0
- package/examples/sdk/12-redis-sessions.ts +54 -0
- package/examples/sdk/13-sql-sessions.ts +61 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +14 -9
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +15 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +63 -22
- package/src/index.ts +3 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/memories/index.ts +8 -3
- package/src/modes/components/custom-editor.ts +3 -0
- package/src/modes/ultrathink.ts +79 -0
- package/src/prompts/system/ultrathink-notice.md +3 -0
- package/src/session/agent-session.ts +28 -0
- package/src/session/redis-session-storage.ts +481 -0
- package/src/session/sql-session-storage.ts +565 -0
- package/src/tools/read.ts +23 -6
- package/src/tools/write.ts +40 -6
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { logger, toError } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Supported `bun:sql` adapter dialects. `Bun.SQL` reports this string on
|
|
6
|
+
* `client.options.adapter`; we detect it once at construction and pick the
|
|
7
|
+
* correct DDL / upsert / concat syntax for the underlying engine.
|
|
8
|
+
*/
|
|
9
|
+
export type SqlSessionStorageAdapter = "postgres" | "mysql" | "sqlite";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal subset of the `Bun.SQL` instance surface used by
|
|
13
|
+
* {@link SqlSessionStorage}. The real client exposes a callable
|
|
14
|
+
* tagged-template too; we only ever call `unsafe()` so the contract here is
|
|
15
|
+
* narrow — making it trivial to swap in a test double or wrap a pooled
|
|
16
|
+
* client.
|
|
17
|
+
*/
|
|
18
|
+
export interface SqlSessionStorageClient {
|
|
19
|
+
unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
|
|
20
|
+
/**
|
|
21
|
+
* `Bun.SQL` exposes the parsed connection options here. We only consult
|
|
22
|
+
* `adapter` to pick the dialect; the field is typed as
|
|
23
|
+
* `string | undefined` so the real `Bun.SQL` instance type slots in
|
|
24
|
+
* without casting (it reports `string | undefined` across adapters).
|
|
25
|
+
*/
|
|
26
|
+
options: { adapter?: string; [key: string]: unknown };
|
|
27
|
+
end?(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SqlSessionStorageOptions {
|
|
31
|
+
/** Connected `Bun.SQL` instance (PostgreSQL, MySQL, or SQLite). */
|
|
32
|
+
client: SqlSessionStorageClient;
|
|
33
|
+
/**
|
|
34
|
+
* Override the auto-detected adapter. Useful when the client is wrapped
|
|
35
|
+
* (e.g. by a pool) and `client.options.adapter` is unreliable.
|
|
36
|
+
*/
|
|
37
|
+
adapter?: SqlSessionStorageAdapter;
|
|
38
|
+
/**
|
|
39
|
+
* Table name to use. Default: `omp_session_files`. Must match
|
|
40
|
+
* `[A-Za-z_][A-Za-z0-9_]{0,62}` — inlined into prepared statements at
|
|
41
|
+
* startup, so we accept identifier-safe inputs only (no quoted/dotted
|
|
42
|
+
* names).
|
|
43
|
+
*/
|
|
44
|
+
table?: string;
|
|
45
|
+
/**
|
|
46
|
+
* If true, run `CREATE TABLE IF NOT EXISTS` during `create()`.
|
|
47
|
+
* Default: true. Disable when the table is owned by an external
|
|
48
|
+
* migration.
|
|
49
|
+
*/
|
|
50
|
+
createTable?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface MirrorEntry {
|
|
54
|
+
content: string;
|
|
55
|
+
mtimeMs: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DialectQueries {
|
|
59
|
+
createTable: string;
|
|
60
|
+
/** Insert or replace the full content for `path`. Used for `writeText`/`flags="w"` truncate. */
|
|
61
|
+
upsertReplace: string;
|
|
62
|
+
/** Insert if missing; otherwise append the new chunk to existing content. Used for `writeLine`. */
|
|
63
|
+
upsertAppend: string;
|
|
64
|
+
/** Delete a single row by path. */
|
|
65
|
+
delete: string;
|
|
66
|
+
/** Delete every row whose `path` starts with the supplied LIKE pattern. */
|
|
67
|
+
deletePrefix: string;
|
|
68
|
+
/** Move a row from one path to another (caller deletes any conflicting destination first). */
|
|
69
|
+
rename: string;
|
|
70
|
+
/** Read everything for the in-memory mirror warm-up. */
|
|
71
|
+
selectAll: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DEFAULT_TABLE = "omp_session_files";
|
|
75
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
76
|
+
const LIKE_ESCAPE_CHAR = "#";
|
|
77
|
+
const LIKE_ESCAPE_RE = /[%_#]/g;
|
|
78
|
+
|
|
79
|
+
function enoent(p: string): NodeJS.ErrnoException {
|
|
80
|
+
const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
|
|
81
|
+
err.code = "ENOENT";
|
|
82
|
+
err.errno = -2;
|
|
83
|
+
err.path = p;
|
|
84
|
+
err.syscall = "open";
|
|
85
|
+
return err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matchesGlob(name: string, pattern: string): boolean {
|
|
89
|
+
if (pattern === "*") return true;
|
|
90
|
+
if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
|
|
91
|
+
return name === pattern;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeLikeLiteral(value: string): string {
|
|
95
|
+
return value.replace(LIKE_ESCAPE_RE, ch => `${LIKE_ESCAPE_CHAR}${ch}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detectAdapter(client: SqlSessionStorageClient): SqlSessionStorageAdapter {
|
|
99
|
+
const reported = String(client.options?.adapter ?? "").toLowerCase();
|
|
100
|
+
if (reported === "postgres" || reported === "postgresql" || reported === "pg") return "postgres";
|
|
101
|
+
if (reported === "mysql" || reported === "mariadb") return "mysql";
|
|
102
|
+
if (reported === "sqlite" || reported === "sqlite3") return "sqlite";
|
|
103
|
+
throw new Error(
|
|
104
|
+
`SqlSessionStorage: unable to infer adapter from client.options.adapter=${JSON.stringify(reported)}. ` +
|
|
105
|
+
`Pass an explicit \`adapter\` option ("postgres" | "mysql" | "sqlite").`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildQueries(adapter: SqlSessionStorageAdapter, table: string): DialectQueries {
|
|
110
|
+
const placeholder = adapter === "postgres" ? (n: number): string => `$${n}` : (_n: number): string => "?";
|
|
111
|
+
|
|
112
|
+
if (adapter === "mysql") {
|
|
113
|
+
return {
|
|
114
|
+
createTable:
|
|
115
|
+
`CREATE TABLE IF NOT EXISTS ${table} (` +
|
|
116
|
+
`path VARCHAR(512) NOT NULL PRIMARY KEY, ` +
|
|
117
|
+
`content LONGTEXT NOT NULL, ` +
|
|
118
|
+
`mtime_ms BIGINT NOT NULL` +
|
|
119
|
+
`) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`,
|
|
120
|
+
upsertReplace:
|
|
121
|
+
`INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
|
|
122
|
+
`ON DUPLICATE KEY UPDATE content = VALUES(content), mtime_ms = VALUES(mtime_ms)`,
|
|
123
|
+
upsertAppend:
|
|
124
|
+
`INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
|
|
125
|
+
`ON DUPLICATE KEY UPDATE content = CONCAT(content, VALUES(content)), mtime_ms = VALUES(mtime_ms)`,
|
|
126
|
+
delete: `DELETE FROM ${table} WHERE path = ?`,
|
|
127
|
+
deletePrefix: `DELETE FROM ${table} WHERE path LIKE ? ESCAPE '${LIKE_ESCAPE_CHAR}'`,
|
|
128
|
+
rename: `UPDATE ${table} SET path = ?, mtime_ms = ? WHERE path = ?`,
|
|
129
|
+
selectAll: `SELECT path, content, mtime_ms FROM ${table}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// PostgreSQL + SQLite — both support `ON CONFLICT(path) DO UPDATE …` and
|
|
134
|
+
// `||` for string concatenation. The `excluded` keyword references the
|
|
135
|
+
// row that would have been inserted, in both engines.
|
|
136
|
+
const mtimeType = adapter === "postgres" ? "BIGINT" : "INTEGER";
|
|
137
|
+
const tableQualifier = `${table}.content`;
|
|
138
|
+
return {
|
|
139
|
+
createTable:
|
|
140
|
+
`CREATE TABLE IF NOT EXISTS ${table} (` +
|
|
141
|
+
`path TEXT PRIMARY KEY, ` +
|
|
142
|
+
`content TEXT NOT NULL, ` +
|
|
143
|
+
`mtime_ms ${mtimeType} NOT NULL` +
|
|
144
|
+
`)`,
|
|
145
|
+
upsertReplace:
|
|
146
|
+
`INSERT INTO ${table} (path, content, mtime_ms) ` +
|
|
147
|
+
`VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
|
|
148
|
+
`ON CONFLICT (path) DO UPDATE SET content = excluded.content, mtime_ms = excluded.mtime_ms`,
|
|
149
|
+
upsertAppend:
|
|
150
|
+
`INSERT INTO ${table} (path, content, mtime_ms) ` +
|
|
151
|
+
`VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
|
|
152
|
+
`ON CONFLICT (path) DO UPDATE SET content = ${tableQualifier} || excluded.content, mtime_ms = excluded.mtime_ms`,
|
|
153
|
+
delete: `DELETE FROM ${table} WHERE path = ${placeholder(1)}`,
|
|
154
|
+
deletePrefix: `DELETE FROM ${table} WHERE path LIKE ${placeholder(1)} ESCAPE '${LIKE_ESCAPE_CHAR}'`,
|
|
155
|
+
rename: `UPDATE ${table} SET path = ${placeholder(1)}, mtime_ms = ${placeholder(2)} WHERE path = ${placeholder(3)}`,
|
|
156
|
+
selectAll: `SELECT path, content, mtime_ms FROM ${table}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface DbRow {
|
|
161
|
+
path: string;
|
|
162
|
+
content: string;
|
|
163
|
+
mtime_ms: number | bigint | string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function rowMtime(value: number | bigint | string): number {
|
|
167
|
+
if (typeof value === "number") return value;
|
|
168
|
+
if (typeof value === "bigint") return Number(value);
|
|
169
|
+
return Number.parseInt(value, 10);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* SQL-backed implementation of {@link SessionStorage} using `bun:sql`. Each
|
|
174
|
+
* session JSONL file maps to a row keyed by `path`; one table stores
|
|
175
|
+
* everything.
|
|
176
|
+
*
|
|
177
|
+
* Works against PostgreSQL, MySQL/MariaDB, and SQLite by selecting the
|
|
178
|
+
* dialect-correct DDL, upsert, and string-concat syntax at construction.
|
|
179
|
+
*
|
|
180
|
+
* Trade-offs vs `FileSessionStorage`:
|
|
181
|
+
* - An in-memory mirror is loaded on construction so the interface's
|
|
182
|
+
* synchronous methods (`existsSync`, `statSync`, `listFilesSync`, …) keep
|
|
183
|
+
* their contracts; `bun:sql` is async only. Mirror state is process-local,
|
|
184
|
+
* matching `FileSessionStorage`'s existing single-writer assumption — peer
|
|
185
|
+
* processes need {@link refresh} to pick up out-of-band writes.
|
|
186
|
+
* - `writeLineSync` updates the mirror synchronously and queues an async
|
|
187
|
+
* upsert that appends the line to the existing row (or inserts it as the
|
|
188
|
+
* first chunk). The promise is awaited by `flush()` / `close()` /
|
|
189
|
+
* {@link drain}. A SIGKILL between the sync mirror update and the network
|
|
190
|
+
* round-trip loses the last line.
|
|
191
|
+
* - Blobs (image data) and tool artifact files still live on disk via
|
|
192
|
+
* `BlobStore` / `ArtifactManager`. Those are out of scope for this storage.
|
|
193
|
+
*/
|
|
194
|
+
export class SqlSessionStorage implements SessionStorage {
|
|
195
|
+
readonly #client: SqlSessionStorageClient;
|
|
196
|
+
readonly #adapter: SqlSessionStorageAdapter;
|
|
197
|
+
readonly #table: string;
|
|
198
|
+
readonly #q: DialectQueries;
|
|
199
|
+
readonly #mirror = new Map<string, MirrorEntry>();
|
|
200
|
+
readonly #writers = new Set<SqlSessionStorageWriter>();
|
|
201
|
+
#nextMtimeMs = 0;
|
|
202
|
+
#pendingTail: Promise<void> = Promise.resolve();
|
|
203
|
+
|
|
204
|
+
private constructor(options: SqlSessionStorageOptions) {
|
|
205
|
+
this.#client = options.client;
|
|
206
|
+
this.#adapter = options.adapter ?? detectAdapter(options.client);
|
|
207
|
+
const table = options.table ?? DEFAULT_TABLE;
|
|
208
|
+
if (!IDENT_RE.test(table)) {
|
|
209
|
+
throw new Error(`SqlSessionStorage: table name must match ${IDENT_RE.source} (got ${JSON.stringify(table)})`);
|
|
210
|
+
}
|
|
211
|
+
this.#table = table;
|
|
212
|
+
this.#q = buildQueries(this.#adapter, table);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Apply the dialect-correct DDL (unless `createTable: false` is set) and
|
|
217
|
+
* warm the in-memory mirror with every existing row. Must be awaited
|
|
218
|
+
* before passing the storage into `SessionManager.create()`.
|
|
219
|
+
*/
|
|
220
|
+
static async create(options: SqlSessionStorageOptions): Promise<SqlSessionStorage> {
|
|
221
|
+
const storage = new SqlSessionStorage(options);
|
|
222
|
+
if (options.createTable !== false) {
|
|
223
|
+
await storage.#client.unsafe(storage.#q.createTable);
|
|
224
|
+
}
|
|
225
|
+
await storage.refresh();
|
|
226
|
+
return storage;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
get adapter(): SqlSessionStorageAdapter {
|
|
230
|
+
return this.#adapter;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
get table(): string {
|
|
234
|
+
return this.#table;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Re-load the mirror from the database. Call this from a different
|
|
239
|
+
* process that took over the table, or after an out-of-band write made
|
|
240
|
+
* by another agent.
|
|
241
|
+
*/
|
|
242
|
+
async refresh(): Promise<void> {
|
|
243
|
+
this.#mirror.clear();
|
|
244
|
+
const rows = (await this.#client.unsafe(this.#q.selectAll)) as DbRow[];
|
|
245
|
+
for (const row of rows) {
|
|
246
|
+
const mtimeMs = rowMtime(row.mtime_ms);
|
|
247
|
+
this.#mirror.set(row.path, { content: row.content, mtimeMs });
|
|
248
|
+
if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Resolve once every pending background write (issued via `writeTextSync`
|
|
254
|
+
* or `writer.writeLineSync`) has been acknowledged by the database.
|
|
255
|
+
* Throws if any background write failed since the last drain. Call on
|
|
256
|
+
* graceful shutdown to avoid losing the last unflushed line.
|
|
257
|
+
*/
|
|
258
|
+
async drain(): Promise<void> {
|
|
259
|
+
// Take ownership of the current tail, then reset so subsequent
|
|
260
|
+
// operations start from a clean (resolved) chain. Without the reset,
|
|
261
|
+
// any failure observed here would also be re-thrown by every later
|
|
262
|
+
// write that piggybacks on the tail via `#trackPending`.
|
|
263
|
+
const tail = this.#pendingTail;
|
|
264
|
+
this.#pendingTail = Promise.resolve();
|
|
265
|
+
await tail;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Allocate a strictly monotonic mtime. Two writes within the same
|
|
270
|
+
* millisecond would otherwise yield identical `mtimeMs` values and break
|
|
271
|
+
* `getSortedSessions`' newest-first ordering.
|
|
272
|
+
*/
|
|
273
|
+
#allocMtimeMs(): number {
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
|
|
276
|
+
this.#nextMtimeMs = next;
|
|
277
|
+
return next;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#trackPending(promise: Promise<void>): void {
|
|
281
|
+
// `Promise.all` rejects when either input rejects, which is exactly
|
|
282
|
+
// what we want for `drain()`. The follow-up `.catch(() => {})` only
|
|
283
|
+
// silences the unhandled-rejection signal on the shared tail —
|
|
284
|
+
// `drain()` keeps its own handler chain and still observes the
|
|
285
|
+
// original error, because rejection delivery is per-handler-chain.
|
|
286
|
+
this.#pendingTail = Promise.all([this.#pendingTail, promise]).then(() => {});
|
|
287
|
+
this.#pendingTail.catch(() => {});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- sync surface ---------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
ensureDirSync(_dir: string): void {
|
|
293
|
+
// SQL is flat: directories are derived from key prefixes.
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
existsSync(path: string): boolean {
|
|
297
|
+
return this.#mirror.has(path);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
writeTextSync(path: string, content: string): void {
|
|
301
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
302
|
+
this.#mirror.set(path, { content, mtimeMs });
|
|
303
|
+
this.#trackPending(this.#upsertReplace(path, content, mtimeMs));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
readTextSync(path: string): string {
|
|
307
|
+
const entry = this.#mirror.get(path);
|
|
308
|
+
if (!entry) throw enoent(path);
|
|
309
|
+
return entry.content;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
statSync(path: string): SessionStorageStat {
|
|
313
|
+
const entry = this.#mirror.get(path);
|
|
314
|
+
if (!entry) throw enoent(path);
|
|
315
|
+
return {
|
|
316
|
+
size: Buffer.byteLength(entry.content, "utf-8"),
|
|
317
|
+
mtimeMs: entry.mtimeMs,
|
|
318
|
+
mtime: new Date(entry.mtimeMs),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
listFilesSync(dir: string, pattern: string): string[] {
|
|
323
|
+
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
|
|
324
|
+
const out: string[] = [];
|
|
325
|
+
for (const path of this.#mirror.keys()) {
|
|
326
|
+
if (!path.startsWith(prefix)) continue;
|
|
327
|
+
const name = path.slice(prefix.length);
|
|
328
|
+
if (name.includes("/")) continue;
|
|
329
|
+
if (!matchesGlob(name, pattern)) continue;
|
|
330
|
+
out.push(path);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- async surface --------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
async exists(path: string): Promise<boolean> {
|
|
338
|
+
return this.#mirror.has(path);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async readText(path: string): Promise<string> {
|
|
342
|
+
const entry = this.#mirror.get(path);
|
|
343
|
+
if (!entry) throw enoent(path);
|
|
344
|
+
return entry.content;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async readTextPrefix(path: string, maxBytes: number): Promise<string> {
|
|
348
|
+
const entry = this.#mirror.get(path);
|
|
349
|
+
if (!entry) throw enoent(path);
|
|
350
|
+
if (maxBytes <= 0) return "";
|
|
351
|
+
// `entry.content` is a JS string (UTF-16 code units); the prefix
|
|
352
|
+
// contract is byte-oriented. Encode to UTF-8, slice, then decode —
|
|
353
|
+
// matching `peekFile`'s behaviour for the file-backed storage.
|
|
354
|
+
const bytes = Buffer.from(entry.content, "utf-8");
|
|
355
|
+
const slice = bytes.subarray(0, Math.min(maxBytes, bytes.byteLength));
|
|
356
|
+
return slice.toString("utf-8");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async writeText(path: string, content: string): Promise<void> {
|
|
360
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
361
|
+
this.#mirror.set(path, { content, mtimeMs });
|
|
362
|
+
await this.#upsertReplace(path, content, mtimeMs);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async rename(src: string, dst: string): Promise<void> {
|
|
366
|
+
const entry = this.#mirror.get(src);
|
|
367
|
+
if (!entry) throw enoent(src);
|
|
368
|
+
// Update the mirror first so a synchronous existsSync() right after
|
|
369
|
+
// the await resolves consistently. If the DB update fails the mirror
|
|
370
|
+
// is rolled back below.
|
|
371
|
+
const dstPrev = this.#mirror.get(dst);
|
|
372
|
+
this.#mirror.delete(src);
|
|
373
|
+
this.#mirror.set(dst, entry);
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
// `fs.promises.rename` overwrites the destination when one
|
|
377
|
+
// exists; mirror that here so the JSONL atomic-rewrite flow
|
|
378
|
+
// (temp file → rename) keeps working unchanged.
|
|
379
|
+
if (dstPrev !== undefined) {
|
|
380
|
+
await this.#client.unsafe(this.#q.delete, [dst]);
|
|
381
|
+
}
|
|
382
|
+
await this.#client.unsafe(this.#q.rename, [dst, entry.mtimeMs, src]);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
this.#mirror.delete(dst);
|
|
385
|
+
if (dstPrev !== undefined) this.#mirror.set(dst, dstPrev);
|
|
386
|
+
this.#mirror.set(src, entry);
|
|
387
|
+
throw toError(err);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async unlink(path: string): Promise<void> {
|
|
392
|
+
const existed = this.#mirror.delete(path);
|
|
393
|
+
await this.#client.unsafe(this.#q.delete, [path]);
|
|
394
|
+
if (!existed) {
|
|
395
|
+
throw enoent(path);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
|
|
400
|
+
await this.unlink(sessionPath);
|
|
401
|
+
|
|
402
|
+
// Tool artifact bytes don't live in SQL (the file-backed
|
|
403
|
+
// `ArtifactManager` keeps them on disk), but a draft sidecar may
|
|
404
|
+
// have been written through `writeText` under the artifacts
|
|
405
|
+
// directory prefix. Sweep those keys in one statement.
|
|
406
|
+
const artifactsDir = sessionPath.slice(0, -6);
|
|
407
|
+
const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
|
|
408
|
+
|
|
409
|
+
const victims: string[] = [];
|
|
410
|
+
for (const key of this.#mirror.keys()) {
|
|
411
|
+
if (key.startsWith(prefix)) victims.push(key);
|
|
412
|
+
}
|
|
413
|
+
if (victims.length === 0) return;
|
|
414
|
+
|
|
415
|
+
for (const key of victims) this.#mirror.delete(key);
|
|
416
|
+
const likePattern = `${escapeLikeLiteral(prefix)}%`;
|
|
417
|
+
try {
|
|
418
|
+
await this.#client.unsafe(this.#q.deletePrefix, [likePattern]);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
logger.warn("SQL session storage artifact sweep failed", {
|
|
421
|
+
sessionPath,
|
|
422
|
+
prefix,
|
|
423
|
+
error: toError(err).message,
|
|
424
|
+
});
|
|
425
|
+
throw toError(err);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
|
|
430
|
+
const writer = new SqlSessionStorageWriter(this, path, options);
|
|
431
|
+
this.#writers.add(writer);
|
|
432
|
+
return writer;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// --- writer support -------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
_writerClosed(writer: SqlSessionStorageWriter): void {
|
|
438
|
+
this.#writers.delete(writer);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_mirrorAppend(path: string, line: string): { content: string; mtimeMs: number } {
|
|
442
|
+
const existing = this.#mirror.get(path);
|
|
443
|
+
const content = existing ? existing.content + line : line;
|
|
444
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
445
|
+
this.#mirror.set(path, { content, mtimeMs });
|
|
446
|
+
return { content, mtimeMs };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
_mirrorTruncate(path: string): void {
|
|
450
|
+
this.#mirror.set(path, { content: "", mtimeMs: this.#allocMtimeMs() });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async _remoteTruncate(path: string): Promise<void> {
|
|
454
|
+
const entry = this.#mirror.get(path);
|
|
455
|
+
const mtimeMs = entry?.mtimeMs ?? this.#allocMtimeMs();
|
|
456
|
+
await this.#upsertReplace(path, "", mtimeMs);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Append a chunk to the row at `path`, inserting if the row doesn't
|
|
461
|
+
* exist yet. Single round-trip via the dialect-specific `upsertAppend`.
|
|
462
|
+
*/
|
|
463
|
+
async _remoteAppend(path: string, line: string, mtimeMs: number): Promise<void> {
|
|
464
|
+
await this.#client.unsafe(this.#q.upsertAppend, [path, line, mtimeMs]);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_attachPending(promise: Promise<void>): void {
|
|
468
|
+
this.#trackPending(promise);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async #upsertReplace(path: string, content: string, mtimeMs: number): Promise<void> {
|
|
472
|
+
await this.#client.unsafe(this.#q.upsertReplace, [path, content, mtimeMs]);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
class SqlSessionStorageWriter implements SessionStorageWriter {
|
|
477
|
+
#storage: SqlSessionStorage;
|
|
478
|
+
#path: string;
|
|
479
|
+
#closed = false;
|
|
480
|
+
#error: Error | undefined;
|
|
481
|
+
#onError: ((err: Error) => void) | undefined;
|
|
482
|
+
#pendingChain: Promise<void> = Promise.resolve();
|
|
483
|
+
|
|
484
|
+
constructor(
|
|
485
|
+
storage: SqlSessionStorage,
|
|
486
|
+
path: string,
|
|
487
|
+
options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
|
|
488
|
+
) {
|
|
489
|
+
this.#storage = storage;
|
|
490
|
+
this.#path = path;
|
|
491
|
+
this.#onError = options?.onError;
|
|
492
|
+
const flags = options?.flags ?? "a";
|
|
493
|
+
if (flags === "w") {
|
|
494
|
+
// Mirror `FileSessionStorageWriter`'s `flags: "w"` contract by
|
|
495
|
+
// truncating both the mirror and the underlying row immediately.
|
|
496
|
+
storage._mirrorTruncate(path);
|
|
497
|
+
this.#enqueueRaw(() => storage._remoteTruncate(path));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#recordError(err: unknown): Error {
|
|
502
|
+
const error = toError(err);
|
|
503
|
+
if (!this.#error) this.#error = error;
|
|
504
|
+
this.#onError?.(error);
|
|
505
|
+
return error;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#enqueueRaw(task: () => Promise<void>): Promise<void> {
|
|
509
|
+
const next = this.#pendingChain.then(async () => {
|
|
510
|
+
if (this.#error) throw this.#error;
|
|
511
|
+
try {
|
|
512
|
+
await task();
|
|
513
|
+
} catch (err) {
|
|
514
|
+
throw this.#recordError(err);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
this.#pendingChain = next.catch(() => {
|
|
518
|
+
// Errors are recorded on `this.#error`; subsequent enqueues
|
|
519
|
+
// throw from inside the wrapper above. The outer chain swallows
|
|
520
|
+
// to avoid surfacing as an unhandled promise rejection.
|
|
521
|
+
});
|
|
522
|
+
this.#storage._attachPending(next);
|
|
523
|
+
return next;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
writeLineSync(line: string): void {
|
|
527
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
528
|
+
if (this.#error) throw this.#error;
|
|
529
|
+
const { mtimeMs } = this.#storage._mirrorAppend(this.#path, line);
|
|
530
|
+
this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line, mtimeMs));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async writeLine(line: string): Promise<void> {
|
|
534
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
535
|
+
if (this.#error) throw this.#error;
|
|
536
|
+
const { mtimeMs } = this.#storage._mirrorAppend(this.#path, line);
|
|
537
|
+
await this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line, mtimeMs));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async flush(): Promise<void> {
|
|
541
|
+
if (this.#error) throw this.#error;
|
|
542
|
+
await this.#enqueueRaw(async () => {});
|
|
543
|
+
if (this.#error) throw this.#error;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async fsync(): Promise<void> {
|
|
547
|
+
// `bun:sql` returns once the server has acknowledged the write;
|
|
548
|
+
// flush() already drains every queued statement.
|
|
549
|
+
await this.flush();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async close(): Promise<void> {
|
|
553
|
+
if (this.#closed) return;
|
|
554
|
+
this.#closed = true;
|
|
555
|
+
try {
|
|
556
|
+
await this.flush();
|
|
557
|
+
} finally {
|
|
558
|
+
this.#storage._writerClosed(this);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
getError(): Error | undefined {
|
|
563
|
+
return this.#error;
|
|
564
|
+
}
|
|
565
|
+
}
|
package/src/tools/read.ts
CHANGED
|
@@ -1063,21 +1063,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1063
1063
|
}
|
|
1064
1064
|
|
|
1065
1065
|
const collectedLines = streamResult.lines;
|
|
1066
|
+
// Column truncation is display-only. The snapshot (sparseSnapshotEntries)
|
|
1067
|
+
// MUST hold on-disk content so later edits can verify line content against
|
|
1068
|
+
// the live file. Stamping ellipsis-truncated lines into the snapshot makes
|
|
1069
|
+
// every long-line file uneditable on the next edit attempt.
|
|
1070
|
+
let displayLines: string[] = collectedLines;
|
|
1066
1071
|
if (!rawSelector && maxColumns > 0) {
|
|
1072
|
+
let cloned: string[] | undefined;
|
|
1067
1073
|
for (let i = 0; i < collectedLines.length; i++) {
|
|
1068
1074
|
const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
|
|
1069
1075
|
if (wasTruncated) {
|
|
1070
|
-
|
|
1076
|
+
if (!cloned) cloned = collectedLines.slice();
|
|
1077
|
+
cloned[i] = text;
|
|
1071
1078
|
columnTruncated = maxColumns;
|
|
1072
1079
|
}
|
|
1073
1080
|
}
|
|
1081
|
+
if (cloned) displayLines = cloned;
|
|
1074
1082
|
}
|
|
1075
1083
|
|
|
1076
1084
|
for (let index = 0; index < collectedLines.length; index++) {
|
|
1077
1085
|
sparseSnapshotEntries.push([range.startLine + index, collectedLines[index]]);
|
|
1078
1086
|
}
|
|
1079
1087
|
|
|
1080
|
-
const blockText =
|
|
1088
|
+
const blockText = displayLines.join("\n");
|
|
1081
1089
|
blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
|
|
1082
1090
|
}
|
|
1083
1091
|
|
|
@@ -1854,17 +1862,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1854
1862
|
// view — column truncation surfaces separately via `.limits()`.
|
|
1855
1863
|
const rawSelector = isRawSelector(parsed);
|
|
1856
1864
|
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
|
1865
|
+
// Column truncation is display-only. `collectedLines` MUST stay
|
|
1866
|
+
// byte-for-byte with the on-disk content so the snapshot recorded
|
|
1867
|
+
// below can be verified against the live file. Mutating it with
|
|
1868
|
+
// ellipsis-truncated text made every long-line file uneditable on
|
|
1869
|
+
// the next edit attempt.
|
|
1870
|
+
let displayLines: string[] = collectedLines;
|
|
1857
1871
|
if (!rawSelector && maxColumns > 0) {
|
|
1872
|
+
let cloned: string[] | undefined;
|
|
1858
1873
|
for (let i = 0; i < collectedLines.length; i++) {
|
|
1859
1874
|
const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
|
|
1860
1875
|
if (wasTruncated) {
|
|
1861
|
-
|
|
1876
|
+
if (!cloned) cloned = collectedLines.slice();
|
|
1877
|
+
cloned[i] = text;
|
|
1862
1878
|
columnTruncated = maxColumns;
|
|
1863
1879
|
}
|
|
1864
1880
|
}
|
|
1881
|
+
if (cloned) displayLines = cloned;
|
|
1865
1882
|
}
|
|
1866
1883
|
|
|
1867
|
-
const selectedContent =
|
|
1884
|
+
const selectedContent = displayLines.join("\n");
|
|
1868
1885
|
const userLimitedLines = collectedLines.length;
|
|
1869
1886
|
|
|
1870
1887
|
const totalSelectedLines = totalFileLines - startLine;
|
|
@@ -1890,9 +1907,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1890
1907
|
if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
|
|
1891
1908
|
const store = getFileSnapshotStore(this.session);
|
|
1892
1909
|
const tag =
|
|
1893
|
-
offset === undefined && limit === undefined && !wasTruncated
|
|
1910
|
+
offset === undefined && limit === undefined && !wasTruncated
|
|
1894
1911
|
? (() => {
|
|
1895
|
-
const normalized = normalizeToLF(
|
|
1912
|
+
const normalized = normalizeToLF(collectedLines.join("\n"));
|
|
1896
1913
|
return store.recordContiguous(absolutePath, 1, normalized.split("\n"), {
|
|
1897
1914
|
fullText: normalized,
|
|
1898
1915
|
});
|