@oh-my-pi/pi-coding-agent 14.5.13 → 14.6.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/commit/pipeline.ts +4 -3
  18. package/src/config/settings-schema.ts +1 -1
  19. package/src/config/settings.ts +20 -1
  20. package/src/config.ts +9 -6
  21. package/src/cursor.ts +1 -1
  22. package/src/edit/index.ts +9 -31
  23. package/src/edit/line-hash.ts +70 -43
  24. package/src/edit/modes/hashline.lark +26 -0
  25. package/src/edit/modes/hashline.ts +898 -1099
  26. package/src/edit/modes/patch.ts +0 -7
  27. package/src/edit/modes/replace.ts +0 -4
  28. package/src/edit/renderer.ts +22 -20
  29. package/src/edit/streaming.ts +8 -28
  30. package/src/eval/eval.lark +24 -30
  31. package/src/eval/js/context-manager.ts +5 -162
  32. package/src/eval/js/prelude.txt +0 -12
  33. package/src/eval/parse.ts +129 -129
  34. package/src/eval/py/kernel.ts +4 -4
  35. package/src/eval/py/prelude.py +1 -219
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +2 -2
  38. package/src/internal-urls/docs-index.generated.ts +1 -1
  39. package/src/main.ts +10 -0
  40. package/src/mcp/manager.ts +22 -0
  41. package/src/modes/components/session-observer-overlay.ts +5 -2
  42. package/src/modes/components/status-line/segments.ts +1 -1
  43. package/src/modes/components/status-line.ts +3 -5
  44. package/src/modes/components/tree-selector.ts +4 -5
  45. package/src/modes/components/welcome.ts +11 -1
  46. package/src/modes/controllers/command-controller.ts +2 -6
  47. package/src/modes/controllers/event-controller.ts +1 -2
  48. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  49. package/src/modes/controllers/input-controller.ts +0 -1
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +5 -7
  52. package/src/modes/rpc/rpc-client.ts +9 -0
  53. package/src/modes/rpc/rpc-mode.ts +6 -0
  54. package/src/modes/rpc/rpc-types.ts +9 -0
  55. package/src/prompts/system/system-prompt.md +14 -38
  56. package/src/prompts/tools/ast-edit.md +8 -8
  57. package/src/prompts/tools/ast-grep.md +10 -10
  58. package/src/prompts/tools/eval.md +13 -31
  59. package/src/prompts/tools/find.md +2 -1
  60. package/src/prompts/tools/hashline.md +66 -57
  61. package/src/prompts/tools/search.md +2 -2
  62. package/src/sdk.ts +19 -4
  63. package/src/session/agent-session.ts +110 -4
  64. package/src/session/session-manager.ts +17 -13
  65. package/src/task/agents.ts +4 -5
  66. package/src/tools/archive-reader.ts +9 -3
  67. package/src/tools/ast-edit.ts +141 -44
  68. package/src/tools/ast-grep.ts +112 -36
  69. package/src/tools/browser/readable.ts +11 -6
  70. package/src/tools/browser/tab-supervisor.ts +2 -2
  71. package/src/tools/browser.ts +5 -3
  72. package/src/tools/eval.ts +2 -53
  73. package/src/tools/find.ts +16 -15
  74. package/src/tools/image-gen.ts +2 -2
  75. package/src/tools/path-utils.ts +36 -196
  76. package/src/tools/search.ts +56 -35
  77. package/src/tools/write.ts +8 -1
  78. package/src/utils/edit-mode.ts +2 -11
  79. package/src/utils/file-display-mode.ts +1 -1
  80. package/src/utils/git.ts +17 -0
  81. package/src/utils/session-color.ts +0 -12
  82. package/src/utils/title-generator.ts +22 -38
  83. package/src/web/scrapers/crossref.ts +3 -3
  84. package/src/web/scrapers/devto.ts +1 -1
  85. package/src/web/scrapers/discourse.ts +5 -5
  86. package/src/web/scrapers/firefox-addons.ts +1 -1
  87. package/src/web/scrapers/flathub.ts +2 -2
  88. package/src/web/scrapers/gitlab.ts +1 -1
  89. package/src/web/scrapers/go-pkg.ts +2 -2
  90. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  91. package/src/web/scrapers/mastodon.ts +9 -9
  92. package/src/web/scrapers/mdn.ts +11 -7
  93. package/src/web/scrapers/pub-dev.ts +1 -1
  94. package/src/web/scrapers/rawg.ts +3 -3
  95. package/src/web/scrapers/readthedocs.ts +1 -1
  96. package/src/web/scrapers/spdx.ts +1 -1
  97. package/src/web/scrapers/stackoverflow.ts +2 -2
  98. package/src/web/scrapers/types.ts +53 -39
  99. package/src/web/scrapers/w3c.ts +1 -1
  100. package/src/web/search/providers/gemini.ts +2 -2
  101. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  102. package/src/autoresearch/contract.ts +0 -288
  103. package/src/edit/modes/atom.lark +0 -29
  104. package/src/edit/modes/atom.ts +0 -1773
  105. package/src/prompts/tools/atom.md +0 -150
@@ -0,0 +1,687 @@
1
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getAutoresearchDbPath, getAutoresearchProjectDir, logger } from "@oh-my-pi/pi-utils";
5
+ import { getEncodedProjectName } from "../task/worktree";
6
+ import * as git from "../utils/git";
7
+ import type { ASIData, ExperimentStatus, MetricDirection, NumericMetricMap } from "./types";
8
+
9
+ export interface SessionRow {
10
+ id: number;
11
+ name: string;
12
+ goal: string | null;
13
+ primaryMetric: string;
14
+ metricUnit: string;
15
+ direction: MetricDirection;
16
+ preferredCommand: string | null;
17
+ branch: string | null;
18
+ baselineCommit: string | null;
19
+ currentSegment: number;
20
+ maxIterations: number | null;
21
+ scopePaths: string[];
22
+ offLimits: string[];
23
+ constraints: string[];
24
+ secondaryMetrics: string[];
25
+ notes: string;
26
+ createdAt: number;
27
+ closedAt: number | null;
28
+ }
29
+
30
+ export interface RunRow {
31
+ id: number;
32
+ sessionId: number;
33
+ segment: number;
34
+ command: string;
35
+ startedAt: number;
36
+ completedAt: number | null;
37
+ durationMs: number | null;
38
+ exitCode: number | null;
39
+ timedOut: boolean;
40
+ parsedPrimary: number | null;
41
+ parsedMetrics: NumericMetricMap | null;
42
+ parsedAsi: ASIData | null;
43
+ preRunDirtyPaths: string[];
44
+ logPath: string;
45
+ status: ExperimentStatus | null;
46
+ description: string | null;
47
+ metric: number | null;
48
+ metrics: NumericMetricMap | null;
49
+ asi: ASIData | null;
50
+ commitHash: string | null;
51
+ confidence: number | null;
52
+ modifiedPaths: string[] | null;
53
+ scopeDeviations: string[] | null;
54
+ justification: string | null;
55
+ flagged: boolean;
56
+ flaggedReason: string | null;
57
+ loggedAt: number | null;
58
+ abandonedAt: number | null;
59
+ }
60
+
61
+ export interface OpenSessionParams {
62
+ name: string;
63
+ goal: string | null;
64
+ primaryMetric: string;
65
+ metricUnit: string;
66
+ direction: MetricDirection;
67
+ preferredCommand: string | null;
68
+ branch: string | null;
69
+ baselineCommit: string | null;
70
+ maxIterations: number | null;
71
+ scopePaths: string[];
72
+ offLimits: string[];
73
+ constraints: string[];
74
+ secondaryMetrics: string[];
75
+ }
76
+
77
+ export interface UpdateSessionParams {
78
+ goal?: string | null;
79
+ preferredCommand?: string | null;
80
+ maxIterations?: number | null;
81
+ scopePaths?: string[];
82
+ offLimits?: string[];
83
+ constraints?: string[];
84
+ secondaryMetrics?: string[];
85
+ primaryMetric?: string;
86
+ metricUnit?: string;
87
+ direction?: MetricDirection;
88
+ branch?: string | null;
89
+ baselineCommit?: string | null;
90
+ notes?: string;
91
+ }
92
+
93
+ export interface InsertRunParams {
94
+ sessionId: number;
95
+ segment: number;
96
+ command: string;
97
+ logPath: string;
98
+ preRunDirtyPaths: string[];
99
+ startedAt: number;
100
+ }
101
+
102
+ export interface MarkRunCompletedParams {
103
+ runId: number;
104
+ completedAt: number;
105
+ durationMs: number;
106
+ exitCode: number | null;
107
+ timedOut: boolean;
108
+ parsedPrimary: number | null;
109
+ parsedMetrics: NumericMetricMap | null;
110
+ parsedAsi: ASIData | null;
111
+ }
112
+
113
+ export interface MarkRunLoggedParams {
114
+ runId: number;
115
+ status: ExperimentStatus;
116
+ description: string;
117
+ metric: number;
118
+ metrics: NumericMetricMap;
119
+ asi: ASIData | null;
120
+ commitHash: string | null;
121
+ confidence: number | null;
122
+ modifiedPaths: string[];
123
+ scopeDeviations: string[];
124
+ justification: string | null;
125
+ loggedAt: number;
126
+ }
127
+
128
+ type SessionDbRow = {
129
+ id: number;
130
+ name: string;
131
+ goal: string | null;
132
+ primary_metric: string;
133
+ metric_unit: string;
134
+ direction: string;
135
+ preferred_command: string | null;
136
+ branch: string | null;
137
+ baseline_commit: string | null;
138
+ current_segment: number;
139
+ max_iterations: number | null;
140
+ scope_paths_json: string;
141
+ off_limits_json: string;
142
+ constraints_json: string;
143
+ secondary_metrics_json: string;
144
+ notes: string;
145
+ created_at: number;
146
+ closed_at: number | null;
147
+ };
148
+
149
+ type RunDbRow = {
150
+ id: number;
151
+ session_id: number;
152
+ segment: number;
153
+ command: string;
154
+ started_at: number;
155
+ completed_at: number | null;
156
+ duration_ms: number | null;
157
+ exit_code: number | null;
158
+ timed_out: number;
159
+ parsed_primary: number | null;
160
+ parsed_metrics_json: string | null;
161
+ parsed_asi_json: string | null;
162
+ pre_run_dirty_paths_json: string;
163
+ log_path: string;
164
+ status: string | null;
165
+ description: string | null;
166
+ metric: number | null;
167
+ metrics_json: string | null;
168
+ asi_json: string | null;
169
+ commit_hash: string | null;
170
+ confidence: number | null;
171
+ modified_paths_json: string | null;
172
+ scope_deviations_json: string | null;
173
+ justification: string | null;
174
+ flagged: number;
175
+ flagged_reason: string | null;
176
+ logged_at: number | null;
177
+ abandoned_at: number | null;
178
+ };
179
+
180
+ const SCHEMA_VERSION = 1;
181
+
182
+ const SCHEMA_SQL = `
183
+ PRAGMA journal_mode=WAL;
184
+ PRAGMA synchronous=NORMAL;
185
+ PRAGMA busy_timeout=5000;
186
+ PRAGMA foreign_keys=ON;
187
+
188
+ CREATE TABLE IF NOT EXISTS sessions (
189
+ id INTEGER PRIMARY KEY,
190
+ name TEXT NOT NULL,
191
+ goal TEXT,
192
+ primary_metric TEXT NOT NULL,
193
+ metric_unit TEXT NOT NULL DEFAULT '',
194
+ direction TEXT NOT NULL DEFAULT 'lower',
195
+ preferred_command TEXT,
196
+ branch TEXT,
197
+ baseline_commit TEXT,
198
+ current_segment INTEGER NOT NULL DEFAULT 0,
199
+ max_iterations INTEGER,
200
+ scope_paths_json TEXT NOT NULL DEFAULT '[]',
201
+ off_limits_json TEXT NOT NULL DEFAULT '[]',
202
+ constraints_json TEXT NOT NULL DEFAULT '[]',
203
+ secondary_metrics_json TEXT NOT NULL DEFAULT '[]',
204
+ notes TEXT NOT NULL DEFAULT '',
205
+ created_at INTEGER NOT NULL,
206
+ closed_at INTEGER
207
+ );
208
+
209
+ CREATE TABLE IF NOT EXISTS runs (
210
+ id INTEGER PRIMARY KEY,
211
+ session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
212
+ segment INTEGER NOT NULL,
213
+ command TEXT NOT NULL,
214
+ started_at INTEGER NOT NULL,
215
+ completed_at INTEGER,
216
+ duration_ms INTEGER,
217
+ exit_code INTEGER,
218
+ timed_out INTEGER NOT NULL DEFAULT 0,
219
+ parsed_primary REAL,
220
+ parsed_metrics_json TEXT,
221
+ parsed_asi_json TEXT,
222
+ pre_run_dirty_paths_json TEXT NOT NULL DEFAULT '[]',
223
+ log_path TEXT NOT NULL,
224
+ status TEXT,
225
+ description TEXT,
226
+ metric REAL,
227
+ metrics_json TEXT,
228
+ asi_json TEXT,
229
+ commit_hash TEXT,
230
+ confidence REAL,
231
+ modified_paths_json TEXT,
232
+ scope_deviations_json TEXT,
233
+ justification TEXT,
234
+ flagged INTEGER NOT NULL DEFAULT 0,
235
+ flagged_reason TEXT,
236
+ logged_at INTEGER,
237
+ abandoned_at INTEGER
238
+ );
239
+
240
+ CREATE INDEX IF NOT EXISTS runs_session_segment_idx ON runs(session_id, segment);
241
+ CREATE INDEX IF NOT EXISTS runs_pending_idx ON runs(session_id, status, abandoned_at);
242
+ `;
243
+
244
+ export class AutoresearchStorage {
245
+ #db: Database;
246
+ #projectDir: string;
247
+ #dbPath: string;
248
+
249
+ constructor(dbPath: string, projectDir: string) {
250
+ this.#dbPath = dbPath;
251
+ this.#projectDir = projectDir;
252
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
253
+ this.#db = new Database(dbPath);
254
+ this.#db.run(SCHEMA_SQL);
255
+ const versionRow = this.#db.query("PRAGMA user_version").get() as { user_version: number } | null;
256
+ const currentVersion = versionRow?.user_version ?? 0;
257
+ if (currentVersion < SCHEMA_VERSION) {
258
+ this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
259
+ }
260
+ }
261
+
262
+ get dbPath(): string {
263
+ return this.#dbPath;
264
+ }
265
+
266
+ get projectDir(): string {
267
+ return this.#projectDir;
268
+ }
269
+
270
+ close(): void {
271
+ this.#db.close();
272
+ }
273
+
274
+ getActiveSession(): SessionRow | null {
275
+ const stmt = this.#db.prepare<SessionDbRow, []>(
276
+ "SELECT * FROM sessions WHERE closed_at IS NULL ORDER BY id DESC LIMIT 1",
277
+ );
278
+ const row = stmt.get();
279
+ return row ? rowToSession(row) : null;
280
+ }
281
+
282
+ getActiveSessionForBranch(branch: string | null): SessionRow | null {
283
+ // Most-recent active session whose recorded branch matches the caller's branch.
284
+ // `branch === null` means "no git repo / no branch info" — treat null on both
285
+ // sides as a match.
286
+ if (branch === null) {
287
+ const stmt = this.#db.prepare<SessionDbRow, []>(
288
+ "SELECT * FROM sessions WHERE closed_at IS NULL AND branch IS NULL ORDER BY id DESC LIMIT 1",
289
+ );
290
+ const row = stmt.get();
291
+ return row ? rowToSession(row) : null;
292
+ }
293
+ const stmt = this.#db.prepare<SessionDbRow, [string]>(
294
+ "SELECT * FROM sessions WHERE closed_at IS NULL AND branch = ? ORDER BY id DESC LIMIT 1",
295
+ );
296
+ const row = stmt.get(branch);
297
+ return row ? rowToSession(row) : null;
298
+ }
299
+
300
+ getSessionById(sessionId: number): SessionRow | null {
301
+ const stmt = this.#db.prepare<SessionDbRow, [number]>("SELECT * FROM sessions WHERE id = ?");
302
+ const row = stmt.get(sessionId);
303
+ return row ? rowToSession(row) : null;
304
+ }
305
+
306
+ openSession(params: OpenSessionParams): SessionRow {
307
+ const stmt = this.#db.prepare<{ id: number }, SQLQueryBindings[]>(
308
+ `INSERT INTO sessions (
309
+ name, goal, primary_metric, metric_unit, direction,
310
+ preferred_command, branch, baseline_commit, max_iterations,
311
+ scope_paths_json, off_limits_json, constraints_json, secondary_metrics_json,
312
+ created_at
313
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
314
+ );
315
+ const row = stmt.get(
316
+ params.name,
317
+ params.goal,
318
+ params.primaryMetric,
319
+ params.metricUnit,
320
+ params.direction,
321
+ params.preferredCommand,
322
+ params.branch,
323
+ params.baselineCommit,
324
+ params.maxIterations,
325
+ JSON.stringify(params.scopePaths),
326
+ JSON.stringify(params.offLimits),
327
+ JSON.stringify(params.constraints),
328
+ JSON.stringify(params.secondaryMetrics),
329
+ Date.now(),
330
+ );
331
+ if (!row) throw new Error("Failed to insert autoresearch session");
332
+ const session = this.getSessionById(row.id);
333
+ if (!session) throw new Error(`Failed to read inserted autoresearch session ${row.id}`);
334
+ return session;
335
+ }
336
+
337
+ updateSession(sessionId: number, updates: UpdateSessionParams): SessionRow {
338
+ const setClauses: string[] = [];
339
+ const values: SQLQueryBindings[] = [];
340
+ if (updates.goal !== undefined) {
341
+ setClauses.push("goal = ?");
342
+ values.push(updates.goal);
343
+ }
344
+ if (updates.preferredCommand !== undefined) {
345
+ setClauses.push("preferred_command = ?");
346
+ values.push(updates.preferredCommand);
347
+ }
348
+ if (updates.maxIterations !== undefined) {
349
+ setClauses.push("max_iterations = ?");
350
+ values.push(updates.maxIterations);
351
+ }
352
+ if (updates.scopePaths !== undefined) {
353
+ setClauses.push("scope_paths_json = ?");
354
+ values.push(JSON.stringify(updates.scopePaths));
355
+ }
356
+ if (updates.offLimits !== undefined) {
357
+ setClauses.push("off_limits_json = ?");
358
+ values.push(JSON.stringify(updates.offLimits));
359
+ }
360
+ if (updates.constraints !== undefined) {
361
+ setClauses.push("constraints_json = ?");
362
+ values.push(JSON.stringify(updates.constraints));
363
+ }
364
+ if (updates.secondaryMetrics !== undefined) {
365
+ setClauses.push("secondary_metrics_json = ?");
366
+ values.push(JSON.stringify(updates.secondaryMetrics));
367
+ }
368
+ if (updates.primaryMetric !== undefined) {
369
+ setClauses.push("primary_metric = ?");
370
+ values.push(updates.primaryMetric);
371
+ }
372
+ if (updates.metricUnit !== undefined) {
373
+ setClauses.push("metric_unit = ?");
374
+ values.push(updates.metricUnit);
375
+ }
376
+ if (updates.direction !== undefined) {
377
+ setClauses.push("direction = ?");
378
+ values.push(updates.direction);
379
+ }
380
+ if (updates.branch !== undefined) {
381
+ setClauses.push("branch = ?");
382
+ values.push(updates.branch);
383
+ }
384
+ if (updates.baselineCommit !== undefined) {
385
+ setClauses.push("baseline_commit = ?");
386
+ values.push(updates.baselineCommit);
387
+ }
388
+ if (updates.notes !== undefined) {
389
+ setClauses.push("notes = ?");
390
+ values.push(updates.notes);
391
+ }
392
+ if (setClauses.length > 0) {
393
+ values.push(sessionId);
394
+ this.#db.prepare(`UPDATE sessions SET ${setClauses.join(", ")} WHERE id = ?`).run(...(values as never[]));
395
+ }
396
+ const session = this.getSessionById(sessionId);
397
+ if (!session) throw new Error(`Session ${sessionId} not found after update`);
398
+ return session;
399
+ }
400
+
401
+ bumpSegment(sessionId: number): SessionRow {
402
+ this.#db.prepare("UPDATE sessions SET current_segment = current_segment + 1 WHERE id = ?").run(sessionId);
403
+ const session = this.getSessionById(sessionId);
404
+ if (!session) throw new Error(`Session ${sessionId} not found after bumping segment`);
405
+ return session;
406
+ }
407
+
408
+ closeSession(sessionId: number): void {
409
+ this.#db.prepare("UPDATE sessions SET closed_at = ? WHERE id = ?").run(Date.now(), sessionId);
410
+ }
411
+
412
+ insertRun(params: InsertRunParams): RunRow {
413
+ const stmt = this.#db.prepare<{ id: number }, SQLQueryBindings[]>(
414
+ `INSERT INTO runs (
415
+ session_id, segment, command, started_at, log_path, pre_run_dirty_paths_json
416
+ ) VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
417
+ );
418
+ const row = stmt.get(
419
+ params.sessionId,
420
+ params.segment,
421
+ params.command,
422
+ params.startedAt,
423
+ params.logPath,
424
+ JSON.stringify(params.preRunDirtyPaths),
425
+ );
426
+ if (!row) throw new Error("Failed to insert run");
427
+ return this.getRunByIdRequired(row.id);
428
+ }
429
+
430
+ updateRunLogPath(runId: number, logPath: string): RunRow {
431
+ this.#db.prepare("UPDATE runs SET log_path = ? WHERE id = ?").run(logPath, runId);
432
+ return this.getRunByIdRequired(runId);
433
+ }
434
+
435
+ updateRunConfidence(runId: number, confidence: number | null): RunRow {
436
+ this.#db.prepare("UPDATE runs SET confidence = ? WHERE id = ?").run(confidence, runId);
437
+ return this.getRunByIdRequired(runId);
438
+ }
439
+
440
+ markRunCompleted(params: MarkRunCompletedParams): RunRow {
441
+ this.#db
442
+ .prepare(
443
+ `UPDATE runs SET
444
+ completed_at = ?, duration_ms = ?, exit_code = ?, timed_out = ?,
445
+ parsed_primary = ?, parsed_metrics_json = ?, parsed_asi_json = ?
446
+ WHERE id = ?`,
447
+ )
448
+ .run(
449
+ params.completedAt,
450
+ params.durationMs,
451
+ params.exitCode,
452
+ params.timedOut ? 1 : 0,
453
+ params.parsedPrimary,
454
+ params.parsedMetrics ? JSON.stringify(params.parsedMetrics) : null,
455
+ params.parsedAsi ? JSON.stringify(params.parsedAsi) : null,
456
+ params.runId,
457
+ );
458
+ return this.getRunByIdRequired(params.runId);
459
+ }
460
+
461
+ markRunLogged(params: MarkRunLoggedParams): RunRow {
462
+ this.#db
463
+ .prepare(
464
+ `UPDATE runs SET
465
+ status = ?, description = ?, metric = ?, metrics_json = ?, asi_json = ?,
466
+ commit_hash = ?, confidence = ?, modified_paths_json = ?, scope_deviations_json = ?,
467
+ justification = ?, logged_at = ?
468
+ WHERE id = ?`,
469
+ )
470
+ .run(
471
+ params.status,
472
+ params.description,
473
+ params.metric,
474
+ JSON.stringify(params.metrics),
475
+ params.asi ? JSON.stringify(params.asi) : null,
476
+ params.commitHash,
477
+ params.confidence,
478
+ JSON.stringify(params.modifiedPaths),
479
+ JSON.stringify(params.scopeDeviations),
480
+ params.justification,
481
+ params.loggedAt,
482
+ params.runId,
483
+ );
484
+ return this.getRunByIdRequired(params.runId);
485
+ }
486
+
487
+ flagRun(runId: number, reason: string): RunRow {
488
+ this.#db.prepare("UPDATE runs SET flagged = 1, flagged_reason = ? WHERE id = ?").run(reason, runId);
489
+ return this.getRunByIdRequired(runId);
490
+ }
491
+
492
+ abandonPendingRuns(sessionId: number): number {
493
+ const beforeRow = this.#db
494
+ .prepare<{ n: number }, [number]>(
495
+ "SELECT COUNT(*) AS n FROM runs WHERE session_id = ? AND status IS NULL AND abandoned_at IS NULL",
496
+ )
497
+ .get(sessionId);
498
+ const before = beforeRow?.n ?? 0;
499
+ if (before === 0) return 0;
500
+ this.#db
501
+ .prepare("UPDATE runs SET abandoned_at = ? WHERE session_id = ? AND status IS NULL AND abandoned_at IS NULL")
502
+ .run(Date.now(), sessionId);
503
+ return before;
504
+ }
505
+
506
+ getPendingRun(sessionId: number): RunRow | null {
507
+ const stmt = this.#db.prepare<RunDbRow, [number]>(
508
+ "SELECT * FROM runs WHERE session_id = ? AND status IS NULL AND abandoned_at IS NULL ORDER BY id DESC LIMIT 1",
509
+ );
510
+ const row = stmt.get(sessionId);
511
+ return row ? rowToRun(row) : null;
512
+ }
513
+
514
+ getRunById(runId: number): RunRow | null {
515
+ const stmt = this.#db.prepare<RunDbRow, [number]>("SELECT * FROM runs WHERE id = ?");
516
+ const row = stmt.get(runId);
517
+ return row ? rowToRun(row) : null;
518
+ }
519
+
520
+ getRunByIdRequired(runId: number): RunRow {
521
+ const run = this.getRunById(runId);
522
+ if (!run) throw new Error(`Run ${runId} not found`);
523
+ return run;
524
+ }
525
+
526
+ listRuns(sessionId: number): RunRow[] {
527
+ const stmt = this.#db.prepare<RunDbRow, [number]>("SELECT * FROM runs WHERE session_id = ? ORDER BY id ASC");
528
+ return stmt.all(sessionId).map(rowToRun);
529
+ }
530
+
531
+ listLoggedRuns(sessionId: number): RunRow[] {
532
+ const stmt = this.#db.prepare<RunDbRow, [number]>(
533
+ "SELECT * FROM runs WHERE session_id = ? AND status IS NOT NULL ORDER BY id ASC",
534
+ );
535
+ return stmt.all(sessionId).map(rowToRun);
536
+ }
537
+ }
538
+
539
+ const storageCache = new Map<string, AutoresearchStorage>();
540
+
541
+ export async function openAutoresearchStorage(cwd: string): Promise<AutoresearchStorage> {
542
+ const { dbPath, projectDir } = await resolveAutoresearchPaths(cwd);
543
+ const cached = storageCache.get(dbPath);
544
+ if (cached) return cached;
545
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
546
+ const storage = new AutoresearchStorage(dbPath, projectDir);
547
+ storageCache.set(dbPath, storage);
548
+ return storage;
549
+ }
550
+
551
+ export async function openAutoresearchStorageIfExists(cwd: string): Promise<AutoresearchStorage | null> {
552
+ const { dbPath, projectDir } = await resolveAutoresearchPaths(cwd);
553
+ const cached = storageCache.get(dbPath);
554
+ if (cached) return cached;
555
+ if (!fs.existsSync(dbPath)) return null;
556
+ const storage = new AutoresearchStorage(dbPath, projectDir);
557
+ storageCache.set(dbPath, storage);
558
+ return storage;
559
+ }
560
+
561
+ async function resolveAutoresearchPaths(cwd: string): Promise<{ dbPath: string; projectDir: string }> {
562
+ const override = process.env.OMP_AUTORESEARCH_DB_DIR;
563
+ const repoRoot = (await git.repo.root(cwd)) ?? cwd;
564
+ const encoded = getEncodedProjectName(repoRoot);
565
+ if (override) {
566
+ return {
567
+ dbPath: path.join(override, `${encoded}.db`),
568
+ projectDir: path.join(override, encoded),
569
+ };
570
+ }
571
+ return {
572
+ dbPath: getAutoresearchDbPath(encoded),
573
+ projectDir: getAutoresearchProjectDir(encoded),
574
+ };
575
+ }
576
+
577
+ export function closeAllAutoresearchStorages(): void {
578
+ for (const storage of storageCache.values()) {
579
+ try {
580
+ storage.close();
581
+ } catch (err) {
582
+ logger.warn("Failed to close autoresearch storage", {
583
+ error: err instanceof Error ? err.message : String(err),
584
+ path: storage.dbPath,
585
+ });
586
+ }
587
+ }
588
+ storageCache.clear();
589
+ }
590
+
591
+ function rowToSession(row: SessionDbRow): SessionRow {
592
+ return {
593
+ id: row.id,
594
+ name: row.name,
595
+ goal: row.goal,
596
+ primaryMetric: row.primary_metric,
597
+ metricUnit: row.metric_unit,
598
+ direction: row.direction === "higher" ? "higher" : "lower",
599
+ preferredCommand: row.preferred_command,
600
+ branch: row.branch,
601
+ baselineCommit: row.baseline_commit,
602
+ currentSegment: row.current_segment,
603
+ maxIterations: row.max_iterations,
604
+ scopePaths: parseStringArray(row.scope_paths_json),
605
+ offLimits: parseStringArray(row.off_limits_json),
606
+ constraints: parseStringArray(row.constraints_json),
607
+ secondaryMetrics: parseStringArray(row.secondary_metrics_json),
608
+ notes: row.notes,
609
+ createdAt: row.created_at,
610
+ closedAt: row.closed_at,
611
+ };
612
+ }
613
+
614
+ function rowToRun(row: RunDbRow): RunRow {
615
+ return {
616
+ id: row.id,
617
+ sessionId: row.session_id,
618
+ segment: row.segment,
619
+ command: row.command,
620
+ startedAt: row.started_at,
621
+ completedAt: row.completed_at,
622
+ durationMs: row.duration_ms,
623
+ exitCode: row.exit_code,
624
+ timedOut: row.timed_out !== 0,
625
+ parsedPrimary: row.parsed_primary,
626
+ parsedMetrics: parseNumericMetricMap(row.parsed_metrics_json),
627
+ parsedAsi: parseAsiData(row.parsed_asi_json),
628
+ preRunDirtyPaths: parseStringArray(row.pre_run_dirty_paths_json),
629
+ logPath: row.log_path,
630
+ status: parseStatus(row.status),
631
+ description: row.description,
632
+ metric: row.metric,
633
+ metrics: parseNumericMetricMap(row.metrics_json),
634
+ asi: parseAsiData(row.asi_json),
635
+ commitHash: row.commit_hash,
636
+ confidence: row.confidence,
637
+ modifiedPaths: row.modified_paths_json !== null ? parseStringArray(row.modified_paths_json) : null,
638
+ scopeDeviations: row.scope_deviations_json !== null ? parseStringArray(row.scope_deviations_json) : null,
639
+ justification: row.justification,
640
+ flagged: row.flagged !== 0,
641
+ flaggedReason: row.flagged_reason,
642
+ loggedAt: row.logged_at,
643
+ abandonedAt: row.abandoned_at,
644
+ };
645
+ }
646
+
647
+ function parseStatus(value: string | null): ExperimentStatus | null {
648
+ if (value === "keep" || value === "discard" || value === "crash" || value === "checks_failed") return value;
649
+ return null;
650
+ }
651
+
652
+ function parseStringArray(json: string): string[] {
653
+ try {
654
+ const parsed = JSON.parse(json) as unknown;
655
+ if (!Array.isArray(parsed)) return [];
656
+ return parsed.filter((value): value is string => typeof value === "string");
657
+ } catch {
658
+ return [];
659
+ }
660
+ }
661
+
662
+ function parseNumericMetricMap(json: string | null): NumericMetricMap | null {
663
+ if (json === null) return null;
664
+ try {
665
+ const parsed = JSON.parse(json) as unknown;
666
+ if (typeof parsed !== "object" || parsed === null) return null;
667
+ const out: NumericMetricMap = {};
668
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
669
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
670
+ if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
671
+ }
672
+ return out;
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
677
+
678
+ function parseAsiData(json: string | null): ASIData | null {
679
+ if (json === null) return null;
680
+ try {
681
+ const parsed = JSON.parse(json) as unknown;
682
+ if (typeof parsed !== "object" || parsed === null) return null;
683
+ return parsed as ASIData;
684
+ } catch {
685
+ return null;
686
+ }
687
+ }