@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.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.
Files changed (172) hide show
  1. package/CHANGELOG.md +113 -31
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/models-config-schema.d.ts +15 -0
  7. package/dist/types/config/settings-schema.d.ts +38 -24
  8. package/dist/types/debug/protocol-probe.d.ts +38 -0
  9. package/dist/types/debug/terminal-info.d.ts +34 -0
  10. package/dist/types/exa/mcp-client.d.ts +2 -1
  11. package/dist/types/export/html/template.generated.d.ts +1 -1
  12. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  14. package/dist/types/extensibility/shared-events.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  22. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  23. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  25. package/dist/types/modes/components/session-selector.d.ts +8 -3
  26. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  27. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  28. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  29. package/dist/types/modes/interactive-mode.d.ts +2 -1
  30. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  31. package/dist/types/modes/theme/theme.d.ts +20 -1
  32. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  33. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  34. package/dist/types/session/agent-session.d.ts +5 -2
  35. package/dist/types/session/history-storage.d.ts +3 -4
  36. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  37. package/dist/types/session/messages.d.ts +1 -0
  38. package/dist/types/session/redis-session-storage.d.ts +12 -85
  39. package/dist/types/session/session-manager.d.ts +21 -0
  40. package/dist/types/session/session-storage.d.ts +5 -7
  41. package/dist/types/session/sql-session-storage.d.ts +16 -85
  42. package/dist/types/slash-commands/types.d.ts +17 -4
  43. package/dist/types/task/executor.d.ts +9 -0
  44. package/dist/types/task/index.d.ts +3 -1
  45. package/dist/types/telemetry-export.d.ts +19 -0
  46. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  47. package/dist/types/tiny/text.d.ts +17 -0
  48. package/dist/types/tools/ask.d.ts +1 -0
  49. package/dist/types/tools/index.d.ts +4 -2
  50. package/dist/types/tools/path-utils.d.ts +1 -1
  51. package/dist/types/tools/search.d.ts +2 -2
  52. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  53. package/dist/types/utils/session-color.d.ts +7 -2
  54. package/dist/types/web/search/index.d.ts +1 -1
  55. package/dist/types/web/search/provider.d.ts +2 -2
  56. package/dist/types/web/search/providers/base.d.ts +14 -0
  57. package/dist/types/web/search/providers/exa.d.ts +9 -0
  58. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  59. package/dist/types/web/search/types.d.ts +66 -1
  60. package/package.json +15 -9
  61. package/scripts/build-binary.ts +12 -0
  62. package/src/capability/skill.ts +7 -0
  63. package/src/cli/args.ts +1 -1
  64. package/src/cli/session-picker.ts +1 -0
  65. package/src/cli/update-cli.ts +54 -2
  66. package/src/commands/completions.ts +1 -1
  67. package/src/config/append-only-context-mode.ts +37 -0
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +24 -57
  70. package/src/debug/index.ts +67 -1
  71. package/src/debug/protocol-probe.ts +267 -0
  72. package/src/debug/terminal-info.ts +127 -0
  73. package/src/exa/mcp-client.ts +11 -5
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +3 -3
  76. package/src/extensibility/custom-tools/types.ts +1 -1
  77. package/src/extensibility/extensions/types.ts +11 -0
  78. package/src/extensibility/shared-events.ts +1 -1
  79. package/src/extensibility/skills.ts +3 -3
  80. package/src/index.ts +1 -0
  81. package/src/internal-urls/docs-index.generated.ts +7 -7
  82. package/src/main.ts +16 -2
  83. package/src/mcp/json-rpc.ts +8 -0
  84. package/src/mcp/render.ts +3 -0
  85. package/src/mcp/tool-bridge.ts +10 -2
  86. package/src/mcp/transports/http.ts +33 -16
  87. package/src/mcp/transports/stdio.ts +37 -12
  88. package/src/mnemopi/state.ts +4 -4
  89. package/src/modes/acp/acp-agent.ts +168 -3
  90. package/src/modes/acp/acp-event-mapper.ts +7 -7
  91. package/src/modes/components/agent-dashboard.ts +103 -31
  92. package/src/modes/components/assistant-message.ts +3 -2
  93. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  94. package/src/modes/components/history-search.ts +128 -14
  95. package/src/modes/components/hook-selector.ts +149 -14
  96. package/src/modes/components/plugin-settings.ts +270 -36
  97. package/src/modes/components/session-selector.ts +81 -19
  98. package/src/modes/components/settings-selector.ts +1 -1
  99. package/src/modes/components/status-line/segments.ts +2 -1
  100. package/src/modes/components/status-line.ts +1 -1
  101. package/src/modes/components/tips.txt +6 -2
  102. package/src/modes/components/todo-reminder.ts +1 -1
  103. package/src/modes/components/tool-execution.ts +9 -4
  104. package/src/modes/components/transcript-container.ts +109 -0
  105. package/src/modes/controllers/command-controller.ts +4 -3
  106. package/src/modes/controllers/event-controller.ts +12 -7
  107. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  108. package/src/modes/controllers/input-controller.ts +10 -5
  109. package/src/modes/controllers/selector-controller.ts +30 -19
  110. package/src/modes/controllers/todo-command-controller.ts +1 -1
  111. package/src/modes/interactive-mode.ts +56 -9
  112. package/src/modes/print-mode.ts +5 -0
  113. package/src/modes/rpc/rpc-types.ts +1 -1
  114. package/src/modes/theme/theme.ts +48 -8
  115. package/src/modes/utils/keybinding-matchers.ts +10 -0
  116. package/src/modes/utils/ui-helpers.ts +1 -0
  117. package/src/plan-mode/plan-handoff.ts +37 -0
  118. package/src/priority.json +4 -0
  119. package/src/prompts/goals/goal-continuation.md +1 -1
  120. package/src/prompts/steering/user-interjection.md +10 -0
  121. package/src/prompts/system/agent-creation-architect.md +1 -26
  122. package/src/prompts/system/eager-todo.md +3 -3
  123. package/src/prompts/system/orchestrate-notice.md +5 -5
  124. package/src/prompts/system/plan-mode-approved.md +14 -17
  125. package/src/prompts/system/plan-mode-reference.md +3 -6
  126. package/src/prompts/system/subagent-system-prompt.md +11 -0
  127. package/src/prompts/system/system-prompt.md +143 -145
  128. package/src/prompts/system/title-system.md +3 -2
  129. package/src/prompts/system/workflow-notice.md +1 -1
  130. package/src/prompts/tools/browser.md +33 -30
  131. package/src/prompts/tools/render-mermaid.md +2 -2
  132. package/src/prompts/tools/search.md +1 -1
  133. package/src/prompts/tools/task.md +3 -1
  134. package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
  135. package/src/sdk.ts +6 -21
  136. package/src/session/agent-session.ts +44 -21
  137. package/src/session/history-storage.ts +11 -18
  138. package/src/session/indexed-session-storage.ts +430 -0
  139. package/src/session/messages.ts +80 -0
  140. package/src/session/redis-session-storage.ts +66 -377
  141. package/src/session/session-manager.ts +109 -23
  142. package/src/session/session-storage.ts +148 -68
  143. package/src/session/sql-session-storage.ts +131 -382
  144. package/src/slash-commands/helpers/todo.ts +2 -2
  145. package/src/slash-commands/types.ts +27 -10
  146. package/src/task/executor.ts +9 -1
  147. package/src/task/index.ts +51 -1
  148. package/src/telemetry-export.ts +126 -0
  149. package/src/tiny/compiled-runtime.ts +179 -0
  150. package/src/tiny/text.ts +112 -1
  151. package/src/tiny/worker.ts +24 -2
  152. package/src/tools/ask.ts +133 -87
  153. package/src/tools/fetch.ts +17 -4
  154. package/src/tools/find.ts +2 -2
  155. package/src/tools/index.ts +6 -4
  156. package/src/tools/memory-recall.ts +1 -1
  157. package/src/tools/memory-reflect.ts +1 -1
  158. package/src/tools/path-utils.ts +16 -7
  159. package/src/tools/renderers.ts +2 -2
  160. package/src/tools/search.ts +6 -3
  161. package/src/tools/ssh.ts +26 -10
  162. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  163. package/src/tools/write.ts +14 -2
  164. package/src/tui/status-line.ts +15 -4
  165. package/src/utils/session-color.ts +39 -14
  166. package/src/utils/title-generator.ts +9 -2
  167. package/src/web/search/index.ts +4 -2
  168. package/src/web/search/provider.ts +19 -35
  169. package/src/web/search/providers/base.ts +17 -0
  170. package/src/web/search/providers/exa.ts +111 -7
  171. package/src/web/search/providers/perplexity.ts +8 -4
  172. package/src/web/search/types.ts +74 -32
@@ -1,19 +1,22 @@
1
- import { logger, toError } from "@oh-my-pi/pi-utils";
2
- import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
1
+ import {
2
+ IndexedSessionStorage,
3
+ type SessionStorageBackend,
4
+ type SessionStorageIndexEntry,
5
+ } from "./indexed-session-storage";
3
6
 
4
7
  /**
5
8
  * Supported `bun:sql` adapter dialects. `Bun.SQL` reports this string on
6
9
  * `client.options.adapter`; we detect it once at construction and pick the
7
- * correct DDL / upsert / concat syntax for the underlying engine.
10
+ * correct DDL / upsert / concat / byte-slice syntax for the underlying engine.
8
11
  */
9
12
  export type SqlSessionStorageAdapter = "postgres" | "mysql" | "sqlite";
10
13
 
11
14
  /**
12
15
  * 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.
16
+ * {@link SqlSessionStorage}. Bun's SQL client exposes a tagged-template API too,
17
+ * but this implementation intentionally uses `unsafe(query, values)` because
18
+ * the table identifier is validated and then inlined while values remain bound
19
+ * parameters.
17
20
  */
18
21
  export interface SqlSessionStorageClient {
19
22
  unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
@@ -50,11 +53,6 @@ export interface SqlSessionStorageOptions {
50
53
  createTable?: boolean;
51
54
  }
52
55
 
53
- interface MirrorEntry {
54
- content: string;
55
- mtimeMs: number;
56
- }
57
-
58
56
  interface DialectQueries {
59
57
  createTable: string;
60
58
  /** Insert or replace the full content for `path`. Used for `writeText`/`flags="w"` truncate. */
@@ -63,18 +61,34 @@ interface DialectQueries {
63
61
  upsertAppend: string;
64
62
  /** Delete a single row by path. */
65
63
  delete: string;
66
- /** Delete every row whose `path` starts with the supplied LIKE pattern. */
67
- deletePrefix: string;
68
64
  /** Move a row from one path to another (caller deletes any conflicting destination first). */
69
65
  rename: string;
70
- /** Read everything for the in-memory mirror warm-up. */
71
- selectAll: string;
66
+ /** Warm the synchronous index without transferring full content. */
67
+ loadIndex: string;
68
+ /** Read the full content for the async `readText` surface. */
69
+ readFull: string;
70
+ /** Read bounded byte windows from the head and tail of the content. */
71
+ readSlices: string;
72
+ }
73
+
74
+ interface IndexRow {
75
+ path: string;
76
+ byte_len: number | bigint | string;
77
+ mtime_ms: number | bigint | string;
78
+ }
79
+
80
+ interface ContentRow {
81
+ content: string;
82
+ }
83
+
84
+ interface SliceRow {
85
+ head: unknown;
86
+ tail: unknown;
72
87
  }
73
88
 
74
89
  const DEFAULT_TABLE = "omp_session_files";
75
90
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
76
- const LIKE_ESCAPE_CHAR = "#";
77
- const LIKE_ESCAPE_RE = /[%_#]/g;
91
+ const utf8Decoder = new TextDecoder("utf-8");
78
92
 
79
93
  function enoent(p: string): NodeJS.ErrnoException {
80
94
  const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
@@ -85,16 +99,6 @@ function enoent(p: string): NodeJS.ErrnoException {
85
99
  return err;
86
100
  }
87
101
 
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
102
  function detectAdapter(client: SqlSessionStorageClient): SqlSessionStorageAdapter {
99
103
  const reported = String(client.options?.adapter ?? "").toLowerCase();
100
104
  if (reported === "postgres" || reported === "postgresql" || reported === "pg") return "postgres";
@@ -124,17 +128,30 @@ function buildQueries(adapter: SqlSessionStorageAdapter, table: string): Dialect
124
128
  `INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
125
129
  `ON DUPLICATE KEY UPDATE content = CONCAT(content, VALUES(content)), mtime_ms = VALUES(mtime_ms)`,
126
130
  delete: `DELETE FROM ${table} WHERE path = ?`,
127
- deletePrefix: `DELETE FROM ${table} WHERE path LIKE ? ESCAPE '${LIKE_ESCAPE_CHAR}'`,
128
131
  rename: `UPDATE ${table} SET path = ?, mtime_ms = ? WHERE path = ?`,
129
- selectAll: `SELECT path, content, mtime_ms FROM ${table}`,
132
+ loadIndex: `SELECT path, mtime_ms, length(content) AS byte_len FROM ${table}`,
133
+ readFull: `SELECT content AS content FROM ${table} WHERE path = ?`,
134
+ readSlices:
135
+ `SELECT substring(cast(content AS binary), 1, ?) AS head, ` +
136
+ `CASE WHEN ? <= 0 THEN cast('' AS binary) ` +
137
+ `ELSE substring(cast(content AS binary), greatest(1, length(content) - ? + 1)) END AS tail ` +
138
+ `FROM ${table} WHERE path = ?`,
130
139
  };
131
140
  }
132
141
 
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
142
  const mtimeType = adapter === "postgres" ? "BIGINT" : "INTEGER";
137
143
  const tableQualifier = `${table}.content`;
144
+ const byteLengthExpr = adapter === "postgres" ? "octet_length(content)" : "length(cast(content AS blob))";
145
+ const readSlices =
146
+ adapter === "postgres"
147
+ ? `SELECT substring(convert_to(content, 'UTF8') from 1 for ${placeholder(1)}) AS head, ` +
148
+ `CASE WHEN ${placeholder(2)} <= 0 THEN ''::bytea ` +
149
+ `ELSE substring(convert_to(content, 'UTF8') from greatest(1, octet_length(content) - ${placeholder(2)} + 1)) END AS tail ` +
150
+ `FROM ${table} WHERE path = ${placeholder(3)}`
151
+ : `SELECT substr(cast(content AS blob), 1, ?) AS head, ` +
152
+ `CASE WHEN ? <= 0 THEN x'' ELSE substr(cast(content AS blob), -?) END AS tail ` +
153
+ `FROM ${table} WHERE path = ?`;
154
+
138
155
  return {
139
156
  createTable:
140
157
  `CREATE TABLE IF NOT EXISTS ${table} (` +
@@ -151,78 +168,56 @@ function buildQueries(adapter: SqlSessionStorageAdapter, table: string): Dialect
151
168
  `VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
152
169
  `ON CONFLICT (path) DO UPDATE SET content = ${tableQualifier} || excluded.content, mtime_ms = excluded.mtime_ms`,
153
170
  delete: `DELETE FROM ${table} WHERE path = ${placeholder(1)}`,
154
- deletePrefix: `DELETE FROM ${table} WHERE path LIKE ${placeholder(1)} ESCAPE '${LIKE_ESCAPE_CHAR}'`,
155
171
  rename: `UPDATE ${table} SET path = ${placeholder(1)}, mtime_ms = ${placeholder(2)} WHERE path = ${placeholder(3)}`,
156
- selectAll: `SELECT path, content, mtime_ms FROM ${table}`,
172
+ loadIndex: `SELECT path, mtime_ms, ${byteLengthExpr} AS byte_len FROM ${table}`,
173
+ readFull: `SELECT content AS content FROM ${table} WHERE path = ${placeholder(1)}`,
174
+ readSlices,
157
175
  };
158
176
  }
159
177
 
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 {
178
+ function rowNumber(value: number | bigint | string): number {
167
179
  if (typeof value === "number") return value;
168
180
  if (typeof value === "bigint") return Number(value);
169
181
  return Number.parseInt(value, 10);
170
182
  }
171
183
 
184
+ function decodeSqlBytes(value: unknown): string {
185
+ if (value === null || value === undefined) return "";
186
+ if (typeof value === "string") return value;
187
+ if (value instanceof Uint8Array) return utf8Decoder.decode(value);
188
+ if (value instanceof ArrayBuffer) return utf8Decoder.decode(new Uint8Array(value));
189
+ return String(value);
190
+ }
191
+
172
192
  /**
173
193
  * 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.
194
+ * session JSONL file maps to a row keyed by `path`; one table stores the file
195
+ * contents while this process keeps only a metadata index (`size`, `mtimeMs`) in
196
+ * memory for synchronous `existsSync` / `statSync` / `listFilesSync` calls.
176
197
  *
177
198
  * 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.
199
+ * dialect-correct DDL, upsert, string-concat, byte-length, and byte-slice syntax
200
+ * at construction.
193
201
  */
194
- export class SqlSessionStorage implements SessionStorage {
195
- readonly #client: SqlSessionStorageClient;
202
+ export class SqlSessionStorage extends IndexedSessionStorage {
196
203
  readonly #adapter: SqlSessionStorageAdapter;
197
204
  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
205
 
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
- }
206
+ constructor(backend: SessionStorageBackend, adapter: SqlSessionStorageAdapter, table: string) {
207
+ super(backend);
208
+ this.#adapter = adapter;
211
209
  this.#table = table;
212
- this.#q = buildQueries(this.#adapter, table);
213
210
  }
214
211
 
215
212
  /**
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()`.
213
+ * Apply the dialect-correct DDL (unless `createTable: false` is set) and warm
214
+ * the metadata index with every existing row. Must be awaited before passing
215
+ * the storage into `SessionManager.create()`.
219
216
  */
220
217
  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();
218
+ const backend = new SqlSessionStorageBackend(options);
219
+ const storage = new SqlSessionStorage(backend, backend.adapter, backend.table);
220
+ await storage.initialize();
226
221
  return storage;
227
222
  }
228
223
 
@@ -233,333 +228,87 @@ export class SqlSessionStorage implements SessionStorage {
233
228
  get table(): string {
234
229
  return this.#table;
235
230
  }
231
+ }
236
232
 
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
- }
233
+ class SqlSessionStorageBackend implements SessionStorageBackend {
234
+ readonly #client: SqlSessionStorageClient;
235
+ readonly #adapter: SqlSessionStorageAdapter;
236
+ readonly #table: string;
237
+ readonly #q: DialectQueries;
238
+ readonly #createTable: boolean;
321
239
 
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);
240
+ constructor(options: SqlSessionStorageOptions) {
241
+ this.#client = options.client;
242
+ this.#adapter = options.adapter ?? detectAdapter(options.client);
243
+ const table = options.table ?? DEFAULT_TABLE;
244
+ if (!IDENT_RE.test(table)) {
245
+ throw new Error(`SqlSessionStorage: table name must match ${IDENT_RE.source} (got ${JSON.stringify(table)})`);
331
246
  }
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);
247
+ this.#table = table;
248
+ this.#q = buildQueries(this.#adapter, table);
249
+ this.#createTable = options.createTable !== false;
363
250
  }
364
251
 
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
- }
252
+ get adapter(): SqlSessionStorageAdapter {
253
+ return this.#adapter;
389
254
  }
390
255
 
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
- }
256
+ get table(): string {
257
+ return this.#table;
397
258
  }
398
259
 
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);
260
+ async init(): Promise<void> {
261
+ if (this.#createTable) {
262
+ await this.#client.unsafe(this.#q.createTable);
426
263
  }
427
264
  }
428
265
 
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;
266
+ async loadIndex(): Promise<SessionStorageIndexEntry[]> {
267
+ const rows = (await this.#client.unsafe(this.#q.loadIndex)) as IndexRow[];
268
+ return rows.map(row => ({
269
+ path: row.path,
270
+ size: rowNumber(row.byte_len),
271
+ mtimeMs: rowNumber(row.mtime_ms),
272
+ }));
433
273
  }
434
274
 
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 };
275
+ async readFull(path: string): Promise<string | null> {
276
+ const rows = (await this.#client.unsafe(this.#q.readFull, [path])) as ContentRow[];
277
+ const row = rows[0];
278
+ return row ? row.content : null;
447
279
  }
448
280
 
449
- _mirrorTruncate(path: string): void {
450
- this.#mirror.set(path, { content: "", mtimeMs: this.#allocMtimeMs() });
281
+ async readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
282
+ const values =
283
+ this.#adapter === "postgres"
284
+ ? [prefixBytes, suffixBytes, path]
285
+ : [prefixBytes, suffixBytes, suffixBytes, path];
286
+ const rows = (await this.#client.unsafe(this.#q.readSlices, values)) as SliceRow[];
287
+ const row = rows[0];
288
+ if (!row) throw enoent(path);
289
+ return [decodeSqlBytes(row.head), decodeSqlBytes(row.tail)];
451
290
  }
452
291
 
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> {
292
+ async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
472
293
  await this.#client.unsafe(this.#q.upsertReplace, [path, content, mtimeMs]);
473
294
  }
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
295
 
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;
296
+ async append(path: string, line: string, mtimeMs: number): Promise<void> {
297
+ await this.#client.unsafe(this.#q.upsertAppend, [path, line, mtimeMs]);
544
298
  }
545
299
 
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();
300
+ async truncate(path: string, mtimeMs: number): Promise<void> {
301
+ await this.writeFull(path, "", mtimeMs);
550
302
  }
551
303
 
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);
304
+ async remove(paths: string[]): Promise<void> {
305
+ for (const path of paths) {
306
+ await this.#client.unsafe(this.#q.delete, [path]);
559
307
  }
560
308
  }
561
309
 
562
- getError(): Error | undefined {
563
- return this.#error;
310
+ async move(src: string, dst: string, mtimeMs: number): Promise<void> {
311
+ await this.#client.unsafe(this.#q.delete, [dst]);
312
+ await this.#client.unsafe(this.#q.rename, [dst, mtimeMs, src]);
564
313
  }
565
314
  }
@@ -1,12 +1,12 @@
1
1
  import * as path from "node:path";
2
- import type { TodoPhase } from "../../tools/todo-write";
2
+ import type { TodoPhase } from "../../tools/todo";
3
3
  import {
4
4
  applyOpsToPhases,
5
5
  getLatestTodoPhasesFromEntries,
6
6
  markdownToPhases,
7
7
  phasesToMarkdown,
8
8
  USER_TODO_EDIT_CUSTOM_TYPE,
9
- } from "../../tools/todo-write";
9
+ } from "../../tools/todo";
10
10
  import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
11
11
  import { commandConsumed, parseSubcommand, usage } from "./parse";
12
12