om-memory-system 3.2.0-next.8 → 3.3.0-next.17

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 (89) hide show
  1. package/README.md +4 -1
  2. package/dist/adapters/opencode/auto-capture-summary.js +50 -8
  3. package/dist/adapters/opencode/import-command.d.ts +3 -7
  4. package/dist/adapters/opencode/import-command.js +3 -54
  5. package/dist/adapters/pi/extension.js +3 -0
  6. package/dist/adapters/pi/profile.js +2 -1
  7. package/dist/adapters/pi/provider.js +41 -14
  8. package/dist/config.d.ts +18 -1
  9. package/dist/config.js +103 -9
  10. package/dist/core/capture-context.js +1 -1
  11. package/dist/core/capture.d.ts +4 -0
  12. package/dist/core/capture.js +30 -0
  13. package/dist/core/extraction.d.ts +23 -1
  14. package/dist/core/extraction.js +41 -0
  15. package/dist/core/host.d.ts +23 -0
  16. package/dist/core/host.js +9 -1
  17. package/dist/core/profile-analysis.js +9 -1
  18. package/dist/importer/discovery.d.ts +32 -5
  19. package/dist/importer/discovery.js +88 -36
  20. package/dist/importer/import-args.d.ts +23 -1
  21. package/dist/importer/import-args.js +29 -1
  22. package/dist/importer/import-project.d.ts +16 -0
  23. package/dist/importer/import-project.js +35 -0
  24. package/dist/importer/import-readiness.d.ts +47 -0
  25. package/dist/importer/import-readiness.js +54 -0
  26. package/dist/importer/import-sessions.d.ts +98 -0
  27. package/dist/importer/import-sessions.js +222 -0
  28. package/dist/importer/import-sources.d.ts +47 -0
  29. package/dist/importer/import-sources.js +147 -0
  30. package/dist/importer/importer.d.ts +31 -1
  31. package/dist/importer/importer.js +82 -24
  32. package/dist/importer/opencode-import.d.ts +11 -1
  33. package/dist/importer/opencode-import.js +133 -69
  34. package/dist/importer/opencode-project.d.ts +1 -4
  35. package/dist/importer/opencode-project.js +3 -36
  36. package/dist/importer/opencode-reader.d.ts +25 -5
  37. package/dist/importer/opencode-reader.js +92 -106
  38. package/dist/importer/opencode-snapshot.d.ts +82 -0
  39. package/dist/importer/opencode-snapshot.js +276 -0
  40. package/dist/importer/profile-import.d.ts +1 -0
  41. package/dist/importer/profile-import.js +7 -1
  42. package/dist/importer/run-import.d.ts +10 -0
  43. package/dist/importer/run-import.js +21 -0
  44. package/dist/importer/settings-health.d.ts +25 -0
  45. package/dist/importer/settings-health.js +136 -0
  46. package/dist/importer/web-import-api.d.ts +8 -0
  47. package/dist/importer/web-import-api.js +8 -0
  48. package/dist/importer/web-import-jobs.d.ts +53 -0
  49. package/dist/importer/web-import-jobs.js +227 -0
  50. package/dist/index.js +4 -0
  51. package/dist/services/ai/live-model-choice.js +6 -3
  52. package/dist/services/ai/opencode-import-models.d.ts +11 -0
  53. package/dist/services/ai/opencode-import-models.js +55 -0
  54. package/dist/services/ai/opencode-provider.d.ts +9 -0
  55. package/dist/services/ai/opencode-provider.js +31 -4
  56. package/dist/services/ai/providers/anthropic-messages.js +14 -6
  57. package/dist/services/ai/providers/base-provider.d.ts +8 -0
  58. package/dist/services/ai/providers/base-provider.js +12 -0
  59. package/dist/services/ai/providers/google-gemini.js +21 -7
  60. package/dist/services/ai/providers/openai-chat-completion.js +20 -7
  61. package/dist/services/ai/providers/openai-responses.js +11 -0
  62. package/dist/services/capture-attempt-store.d.ts +21 -0
  63. package/dist/services/capture-attempt-store.js +108 -0
  64. package/dist/services/capture-diagnostics.d.ts +66 -0
  65. package/dist/services/capture-diagnostics.js +175 -0
  66. package/dist/services/cleanup-service.js +9 -0
  67. package/dist/services/global-config-writer.d.ts +10 -0
  68. package/dist/services/global-config-writer.js +106 -0
  69. package/dist/services/log-path.d.ts +2 -0
  70. package/dist/services/log-path.js +15 -0
  71. package/dist/services/logger.js +1 -12
  72. package/dist/services/settings-log.d.ts +5 -0
  73. package/dist/services/settings-log.js +36 -0
  74. package/dist/services/settings-models.d.ts +35 -0
  75. package/dist/services/settings-models.js +39 -0
  76. package/dist/services/settings-snapshot.d.ts +24 -0
  77. package/dist/services/settings-snapshot.js +71 -0
  78. package/dist/services/settings-traces.d.ts +7 -0
  79. package/dist/services/settings-traces.js +39 -0
  80. package/dist/services/user-memory-learning.js +2 -1
  81. package/dist/services/web-server.d.ts +5 -0
  82. package/dist/services/web-server.js +176 -3
  83. package/dist/v2/legacy-client.js +19 -2
  84. package/dist/web/assets/index-BMkPht7g.js +124 -0
  85. package/dist/web/assets/index-D5ak5KsI.css +2 -0
  86. package/dist/web/index.html +2 -2
  87. package/package.json +2 -1
  88. package/dist/web/assets/index-DPjEpD1h.css +0 -2
  89. package/dist/web/assets/index-do26c4JJ.js +0 -122
@@ -1,11 +1,11 @@
1
- import { constants, copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
2
- import { tmpdir } from "node:os";
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
  import { INTERNAL_CAPTURE_SESSION_TITLES } from "../services/ai/internal-capture-sessions.js";
7
- import { log } from "../services/logger.js";
8
- import { withSqliteFileLockRetry } from "../services/turso/sqlite-handle-release.js";
7
+ import { acquirePrivateSnapshot, opencodeSnapshots, OpencodeSnapshotError, } from "./opencode-snapshot.js";
8
+ export const DEFAULT_OPENCODE_DB = join(homedir(), ".local/share/opencode/opencode.db");
9
9
  function validateSchema(db) {
10
10
  const required = {
11
11
  session: ["id", "directory", "project_id", "parent_id", "time_created"],
@@ -113,121 +113,107 @@ function fileStamp(path) {
113
113
  const info = statSync(path, { bigint: true });
114
114
  return `${info.size}:${info.mtimeNs}`;
115
115
  }
116
- /**
117
- * Copy the database and its WAL into a private folder. `immutable=1` ignores
118
- * the WAL, and a normal open writes to OpenCode's `-shm`, so a copy is the only
119
- * way to see uncheckpointed turns while leaving every source file untouched.
120
- * A copy that raced a write or checkpoint is discarded and retried.
121
- */
122
- /**
123
- * Remove a snapshot folder without ever failing the import. Windows can hold
124
- * SQLite files briefly after close, so retry lock errors; if the lock
125
- * outlasts the retries, log the leftover path instead of throwing.
126
- */
127
- async function removeSnapshotDir(dir) {
128
- try {
129
- await withSqliteFileLockRetry(() => rmSync(dir, { recursive: true, force: true }));
130
- }
131
- catch (error) {
132
- log("OpenCode import: could not remove the temporary database copy", {
133
- dir,
134
- error: String(error),
135
- });
136
- }
137
- }
138
- function snapshotWalDatabase(dbPath) {
139
- const dir = mkdtempSync(join(tmpdir(), "omms-opencode-"));
140
- const cleanup = () => removeSnapshotDir(dir);
141
- const copy = join(dir, "opencode.db");
116
+ async function openSnapshot(dbPath, options) {
117
+ const lease = options.shared
118
+ ? await opencodeSnapshots.acquire(options.shared.key, dbPath, options.shared.mode, options.signal)
119
+ : await acquirePrivateSnapshot(dbPath, options.signal);
142
120
  try {
143
- for (let attempt = 0; attempt < 5; attempt++) {
144
- const before = [fileStamp(dbPath), fileStamp(`${dbPath}-wal`)];
145
- // Clone where the file system supports it (APFS, Btrfs): large databases copy instantly.
146
- copyFileSync(dbPath, copy, constants.COPYFILE_FICLONE);
147
- copyFileSync(`${dbPath}-wal`, `${copy}-wal`, constants.COPYFILE_FICLONE);
148
- const after = [fileStamp(dbPath), fileStamp(`${dbPath}-wal`)];
149
- if (before[0] === after[0] && before[1] === after[1])
150
- return { path: copy, cleanup };
151
- }
121
+ return { db: new DatabaseSync(lease.path), lease };
152
122
  }
153
123
  catch (error) {
154
- void cleanup();
124
+ await lease.release();
155
125
  throw error;
156
126
  }
157
- void cleanup();
158
- throw new Error("OpenCode database kept changing while it was copied; retry the import");
159
127
  }
160
- function openSnapshot(dbPath) {
161
- if (!existsSync(`${dbPath}-wal`)) {
162
- // No WAL: every committed page is in the main file, so read it in place.
163
- const uri = `${pathToFileURL(dbPath).href}?immutable=1`;
164
- return { db: new DatabaseSync(uri, { readOnly: true }), cleanup: async () => { } };
165
- }
166
- const snapshot = snapshotWalDatabase(dbPath);
167
- try {
168
- return { db: new DatabaseSync(snapshot.path), cleanup: snapshot.cleanup };
169
- }
170
- catch (error) {
171
- void snapshot.cleanup();
172
- throw error;
128
+ /**
129
+ * Without a WAL every committed page is in the main file, so read it in place
130
+ * with `immutable=1`. A rollback journal means a writer is mid-transaction,
131
+ * which an immutable read could see half-done.
132
+ */
133
+ async function openSource(dbPath, options) {
134
+ if (existsSync(`${dbPath}-wal`))
135
+ return openSnapshot(dbPath, options);
136
+ if (existsSync(`${dbPath}-journal`)) {
137
+ throw new OpencodeSnapshotError("busy", "The OpenCode database is being written. Retry in a moment, or choose a backup.");
173
138
  }
139
+ const stamp = fileStamp(dbPath);
140
+ const uri = `${pathToFileURL(dbPath).href}?immutable=1`;
141
+ return { db: new DatabaseSync(uri, { readOnly: true }), stamp };
142
+ }
143
+ /** True when an in-place read may have raced a writer that started meanwhile. */
144
+ function changedSinceOpen(dbPath, source) {
145
+ if (source.stamp === undefined)
146
+ return false;
147
+ return existsSync(`${dbPath}-wal`) || fileStamp(dbPath) !== source.stamp;
148
+ }
149
+ function readSessions(db, filters) {
150
+ validateSchema(db);
151
+ // omms's own capture/profile calls run in OpenCode sessions; never import them as history.
152
+ const hasTitle = db.prepare("PRAGMA table_info(session)").all().some((column) => column.name === "title");
153
+ const notInternal = hasTitle
154
+ ? `AND (s.title IS NULL OR s.title NOT IN (${INTERNAL_CAPTURE_SESSION_TITLES.map(() => "?").join(", ")}))`
155
+ : "";
156
+ const internalArgs = hasTitle ? [...INTERNAL_CAPTURE_SESSION_TITLES] : [];
157
+ const count = (where) => Number(db
158
+ .prepare(`SELECT COUNT(*) AS count FROM session s WHERE ${where} ${notInternal}`)
159
+ .get(...internalArgs).count);
160
+ const query = `SELECT s.id, s.directory, p.worktree AS project_worktree, s.time_created
161
+ FROM session s LEFT JOIN project p ON p.id = s.project_id
162
+ WHERE s.parent_id IS NULL ${notInternal} ${filters.session ? "AND s.id = ?" : ""}
163
+ ${filters.sessionIds ? "AND s.id IN (SELECT value FROM json_each(?))" : ""}
164
+ ORDER BY s.time_created, s.id ${filters.maxSessions ? "LIMIT ?" : ""}`;
165
+ const args = [...internalArgs];
166
+ if (filters.session)
167
+ args.push(filters.session);
168
+ if (filters.sessionIds)
169
+ args.push(JSON.stringify(filters.sessionIds));
170
+ if (filters.maxSessions)
171
+ args.push(filters.maxSessions);
172
+ const sessions = db.prepare(query).all(...args).map((row) => ({
173
+ sessionId: row.id,
174
+ recordedDirectory: row.directory,
175
+ projectWorktree: row.project_worktree,
176
+ timeCreated: Number(row.time_created),
177
+ }));
178
+ return {
179
+ childSessions: count("s.parent_id IS NOT NULL"),
180
+ topLevelSessions: count("s.parent_id IS NULL"),
181
+ sessions,
182
+ };
174
183
  }
175
184
  /** Read V1 OpenCode history without changing its database or WAL sidecars. */
176
- export function readOpencodeHistory(dbPath, filters = {}) {
185
+ export async function openOpencodeHistory(dbPath, filters = {}, options = {}) {
177
186
  if (!existsSync(dbPath))
178
- throw new Error(`OpenCode database not found: ${dbPath}`);
179
- const { db, cleanup } = openSnapshot(dbPath);
180
- const close = async () => {
181
- db.close();
182
- await cleanup();
183
- };
187
+ throw new Error("OpenCode database not found");
188
+ let source = await openSource(dbPath, options);
189
+ let listed;
184
190
  try {
185
- validateSchema(db);
186
- // omms's own capture/profile calls run in OpenCode sessions; never import them as history.
187
- const hasTitle = db.prepare("PRAGMA table_info(session)").all().some((column) => column.name === "title");
188
- const notInternal = hasTitle
189
- ? `AND (s.title IS NULL OR s.title NOT IN (${INTERNAL_CAPTURE_SESSION_TITLES.map(() => "?").join(", ")}))`
190
- : "";
191
- const internalArgs = hasTitle ? [...INTERNAL_CAPTURE_SESSION_TITLES] : [];
192
- const count = (where) => Number(db
193
- .prepare(`SELECT COUNT(*) AS count FROM session s WHERE ${where} ${notInternal}`)
194
- .get(...internalArgs).count);
195
- const childSessions = count("s.parent_id IS NOT NULL");
196
- const topLevelSessions = count("s.parent_id IS NULL");
197
- const sessions = {
198
- async *[Symbol.asyncIterator]() {
199
- try {
200
- const query = `SELECT s.id, s.directory, p.worktree AS project_worktree, s.time_created
201
- FROM session s LEFT JOIN project p ON p.id = s.project_id
202
- WHERE s.parent_id IS NULL ${notInternal} ${filters.session ? "AND s.id = ?" : ""}
203
- ORDER BY s.time_created, s.id ${filters.maxSessions ? "LIMIT ?" : ""}`;
204
- const args = [...internalArgs];
205
- if (filters.session)
206
- args.push(filters.session);
207
- if (filters.maxSessions)
208
- args.push(filters.maxSessions);
209
- for (const row of db.prepare(query).all(...args)) {
210
- yield {
211
- sessionId: row.id,
212
- recordedDirectory: row.directory,
213
- directory: row.directory,
214
- sourceFile: dbPath,
215
- projectWorktree: row.project_worktree,
216
- timeCreated: row.time_created,
217
- units: buildWindows(db, row.id, filters),
218
- };
219
- }
220
- }
221
- finally {
222
- await close();
223
- }
224
- },
225
- };
226
- return { childSessions, topLevelSessions, sessions };
191
+ listed = readSessions(source.db, filters);
192
+ if (changedSinceOpen(dbPath, source)) {
193
+ // OpenCode started writing during the in-place read: read a private copy instead.
194
+ source.db.close();
195
+ source = undefined;
196
+ source = await openSnapshot(dbPath, options);
197
+ listed = readSessions(source.db, filters);
198
+ }
227
199
  }
228
200
  catch (error) {
229
- // Cleanup never throws, so the original error is what the caller sees.
230
- void close();
201
+ // Close only what is still open, so the original error is what the caller sees.
202
+ source?.db.close();
203
+ await source?.lease?.release();
231
204
  throw error;
232
205
  }
206
+ const opened = source;
207
+ let closed = false;
208
+ return {
209
+ ...listed,
210
+ loadUnits: (sessionId) => buildWindows(opened.db, sessionId, filters),
211
+ close: async () => {
212
+ if (closed)
213
+ return;
214
+ closed = true;
215
+ opened.db.close();
216
+ await opened.lease?.release();
217
+ },
218
+ };
233
219
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Private copies of a live OpenCode database. A normal SQLite open writes to
3
+ * OpenCode's `-shm`, and `immutable=1` ignores the WAL, so a copy of the
4
+ * database and its WAL is the only way to read uncheckpointed turns while
5
+ * leaving every source file untouched.
6
+ *
7
+ * Copies run asynchronously so the OpenCode process that hosts the web UI and
8
+ * live capture keeps responding, and they can be cancelled. One copy per
9
+ * source is shared by the web listing, preview, and import.
10
+ */
11
+ export declare const SNAPSHOT_PREFIX = "omms-opencode-";
12
+ export declare const SNAPSHOT_IDLE_MS: number;
13
+ /** Sources above this size, or on another device, get one copy attempt instead of five. */
14
+ export declare const SINGLE_ATTEMPT_BYTES: number;
15
+ export type SnapshotErrorCode = "no-space" | "changing" | "busy" | "expired";
16
+ export declare class OpencodeSnapshotError extends Error {
17
+ readonly code: SnapshotErrorCode;
18
+ constructor(code: SnapshotErrorCode, message: string);
19
+ }
20
+ export interface SnapshotLease {
21
+ /** Path of the private database copy. */
22
+ path: string;
23
+ release: () => Promise<void>;
24
+ }
25
+ /** Bytes a snapshot of `dbPath` needs in the temporary folder, margin included. */
26
+ export declare function snapshotSpaceNeeded(dbPath: string): number;
27
+ export declare function checkSnapshotSpace(dbPath: string, folder?: string, available?: number): void;
28
+ /**
29
+ * Remove a snapshot folder without ever failing the import. Windows can hold
30
+ * SQLite files briefly after close, so retry lock errors; if the lock
31
+ * outlasts the retries, log the leftover path instead of throwing.
32
+ */
33
+ export declare function removeSnapshotDir(dir: string): Promise<void>;
34
+ /**
35
+ * Delete snapshot folders left by processes that no longer run, such as an
36
+ * OpenCode window that quit mid-import. A folder whose owner is alive, or
37
+ * whose owner file is unreadable because it is still being created, is kept.
38
+ */
39
+ export declare function sweepOrphanSnapshots(folder?: string): Promise<number>;
40
+ /**
41
+ * Copy `dbPath` and its WAL into a private folder. A copy that raced a write
42
+ * or checkpoint is discarded and retried, but only once for large or
43
+ * cross-device sources, where each attempt can take minutes.
44
+ */
45
+ export declare function createOpencodeSnapshot(dbPath: string, options?: {
46
+ signal?: AbortSignal;
47
+ folder?: string;
48
+ clone?: boolean;
49
+ }): Promise<{
50
+ dir: string;
51
+ path: string;
52
+ }>;
53
+ /**
54
+ * Snapshots shared by source key (real path, device, inode). A fresh request
55
+ * replaces the current copy for later callers; a job still reading the old
56
+ * copy keeps it until it releases it.
57
+ */
58
+ export declare class OpencodeSnapshotRegistry {
59
+ private readonly idleMs;
60
+ private readonly create;
61
+ private readonly current;
62
+ private readonly pending;
63
+ private readonly all;
64
+ constructor(idleMs?: number, create?: typeof createOpencodeSnapshot);
65
+ /**
66
+ * `fresh` makes a new copy (a listing refresh); `reuse` only returns an
67
+ * existing copy and fails as expired when there is none (a job).
68
+ */
69
+ acquire(key: string, dbPath: string, mode: "fresh" | "reuse" | "any", signal?: AbortSignal): Promise<SnapshotLease>;
70
+ private retire;
71
+ private remove;
72
+ /**
73
+ * Drop the current copy for a source after a real import: a later listing
74
+ * copies afresh. A job still reading it keeps it until it releases it.
75
+ */
76
+ discard(key: string): void;
77
+ /** Remove every copy, for server shutdown. */
78
+ closeAll(): Promise<void>;
79
+ }
80
+ export declare const opencodeSnapshots: OpencodeSnapshotRegistry;
81
+ /** An unshared snapshot for one CLI or slash-command run. */
82
+ export declare function acquirePrivateSnapshot(dbPath: string, signal?: AbortSignal): Promise<SnapshotLease>;
@@ -0,0 +1,276 @@
1
+ import { constants, createReadStream, createWriteStream, mkdtempSync, readdirSync, readFileSync, rmSync, statfsSync, statSync, writeFileSync, } from "node:fs";
2
+ import { copyFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { log } from "../services/logger.js";
7
+ import { withSqliteFileLockRetry } from "../services/turso/sqlite-handle-release.js";
8
+ /**
9
+ * Private copies of a live OpenCode database. A normal SQLite open writes to
10
+ * OpenCode's `-shm`, and `immutable=1` ignores the WAL, so a copy of the
11
+ * database and its WAL is the only way to read uncheckpointed turns while
12
+ * leaving every source file untouched.
13
+ *
14
+ * Copies run asynchronously so the OpenCode process that hosts the web UI and
15
+ * live capture keeps responding, and they can be cancelled. One copy per
16
+ * source is shared by the web listing, preview, and import.
17
+ */
18
+ export const SNAPSHOT_PREFIX = "omms-opencode-";
19
+ export const SNAPSHOT_IDLE_MS = 30 * 60 * 1000;
20
+ /** Sources above this size, or on another device, get one copy attempt instead of five. */
21
+ export const SINGLE_ATTEMPT_BYTES = 1024 ** 3;
22
+ const MIN_MARGIN_BYTES = 256 * 1024 ** 2;
23
+ const OWNER_FILE = "owner.json";
24
+ export class OpencodeSnapshotError extends Error {
25
+ code;
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.code = code;
29
+ this.name = "OpencodeSnapshotError";
30
+ }
31
+ }
32
+ function formatBytes(bytes) {
33
+ if (bytes >= 1024 ** 3)
34
+ return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
35
+ return `${Math.ceil(bytes / 1024 ** 2)} MB`;
36
+ }
37
+ function fileStamp(path) {
38
+ const info = statSync(path, { bigint: true });
39
+ return `${info.size}:${info.mtimeNs}`;
40
+ }
41
+ function fileSize(path) {
42
+ try {
43
+ return statSync(path).size;
44
+ }
45
+ catch {
46
+ return 0;
47
+ }
48
+ }
49
+ /** Bytes a snapshot of `dbPath` needs in the temporary folder, margin included. */
50
+ export function snapshotSpaceNeeded(dbPath) {
51
+ const total = fileSize(dbPath) + fileSize(`${dbPath}-wal`);
52
+ return total + Math.max(MIN_MARGIN_BYTES, Math.ceil(total * 0.1));
53
+ }
54
+ export function checkSnapshotSpace(dbPath, folder = tmpdir(), available = (() => {
55
+ const info = statfsSync(folder);
56
+ return Number(info.bavail) * Number(info.bsize);
57
+ })()) {
58
+ const needed = snapshotSpaceNeeded(dbPath);
59
+ if (available < needed) {
60
+ throw new OpencodeSnapshotError("no-space", `Not enough temporary space to copy the OpenCode database: needs ${formatBytes(needed)}, ` +
61
+ `${formatBytes(available)} free in ${folder}. Free space there or choose a checkpointed backup.`);
62
+ }
63
+ }
64
+ /**
65
+ * Remove a snapshot folder without ever failing the import. Windows can hold
66
+ * SQLite files briefly after close, so retry lock errors; if the lock
67
+ * outlasts the retries, log the leftover path instead of throwing.
68
+ */
69
+ export async function removeSnapshotDir(dir) {
70
+ try {
71
+ await withSqliteFileLockRetry(() => rmSync(dir, { recursive: true, force: true }));
72
+ }
73
+ catch (error) {
74
+ log("OpenCode import: could not remove the temporary database copy", {
75
+ dir,
76
+ code: error.code ?? "error",
77
+ });
78
+ }
79
+ }
80
+ function processAlive(pid) {
81
+ try {
82
+ process.kill(pid, 0);
83
+ return true;
84
+ }
85
+ catch (error) {
86
+ // EPERM: the process exists but belongs to someone else.
87
+ return error.code === "EPERM";
88
+ }
89
+ }
90
+ /**
91
+ * Delete snapshot folders left by processes that no longer run, such as an
92
+ * OpenCode window that quit mid-import. A folder whose owner is alive, or
93
+ * whose owner file is unreadable because it is still being created, is kept.
94
+ */
95
+ export async function sweepOrphanSnapshots(folder = tmpdir()) {
96
+ let removed = 0;
97
+ let entries;
98
+ try {
99
+ entries = readdirSync(folder).filter((name) => name.startsWith(SNAPSHOT_PREFIX));
100
+ }
101
+ catch {
102
+ return 0;
103
+ }
104
+ for (const name of entries) {
105
+ const dir = join(folder, name);
106
+ let owner;
107
+ try {
108
+ owner = JSON.parse(readFileSync(join(dir, OWNER_FILE), "utf8"));
109
+ }
110
+ catch {
111
+ continue;
112
+ }
113
+ if (typeof owner.pid !== "number" || processAlive(owner.pid))
114
+ continue;
115
+ await removeSnapshotDir(dir);
116
+ removed++;
117
+ }
118
+ return removed;
119
+ }
120
+ async function copyOne(source, target, signal, clone = true) {
121
+ signal?.throwIfAborted();
122
+ if (clone) {
123
+ try {
124
+ // A copy-on-write clone (APFS, Btrfs) is instant and runs off the event loop.
125
+ await copyFile(source, target, constants.COPYFILE_FICLONE_FORCE);
126
+ return;
127
+ }
128
+ catch {
129
+ // No clone support here, or another device: stream the bytes instead.
130
+ }
131
+ }
132
+ await pipeline(createReadStream(source), createWriteStream(target), { signal });
133
+ }
134
+ /**
135
+ * Copy `dbPath` and its WAL into a private folder. A copy that raced a write
136
+ * or checkpoint is discarded and retried, but only once for large or
137
+ * cross-device sources, where each attempt can take minutes.
138
+ */
139
+ export async function createOpencodeSnapshot(dbPath, options = {}) {
140
+ const folder = options.folder ?? tmpdir();
141
+ await sweepOrphanSnapshots(folder);
142
+ checkSnapshotSpace(dbPath, folder);
143
+ const wal = `${dbPath}-wal`;
144
+ const total = fileSize(dbPath) + fileSize(wal);
145
+ let sameDevice = false;
146
+ try {
147
+ sameDevice = statSync(folder).dev === statSync(dbPath).dev;
148
+ }
149
+ catch {
150
+ // Unknown device: treat it as another one.
151
+ }
152
+ const attempts = sameDevice && total <= SINGLE_ATTEMPT_BYTES ? 5 : 1;
153
+ const dir = mkdtempSync(join(folder, SNAPSHOT_PREFIX));
154
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
155
+ const copy = join(dir, "opencode.db");
156
+ try {
157
+ for (let attempt = 0; attempt < attempts; attempt++) {
158
+ const before = [fileStamp(dbPath), fileStamp(wal)];
159
+ await copyOne(dbPath, copy, options.signal, options.clone);
160
+ await copyOne(wal, `${copy}-wal`, options.signal, options.clone);
161
+ const after = [fileStamp(dbPath), fileStamp(wal)];
162
+ if (before[0] === after[0] && before[1] === after[1])
163
+ return { dir, path: copy };
164
+ }
165
+ }
166
+ catch (error) {
167
+ await removeSnapshotDir(dir);
168
+ throw error;
169
+ }
170
+ await removeSnapshotDir(dir);
171
+ throw new OpencodeSnapshotError("changing", attempts === 1
172
+ ? "The OpenCode database changed while it was copied. Quit OpenCode, or choose a checkpointed backup, and retry."
173
+ : "OpenCode database kept changing while it was copied; retry the import");
174
+ }
175
+ /**
176
+ * Snapshots shared by source key (real path, device, inode). A fresh request
177
+ * replaces the current copy for later callers; a job still reading the old
178
+ * copy keeps it until it releases it.
179
+ */
180
+ export class OpencodeSnapshotRegistry {
181
+ idleMs;
182
+ create;
183
+ current = new Map();
184
+ pending = new Map();
185
+ all = new Set();
186
+ constructor(idleMs = SNAPSHOT_IDLE_MS, create = createOpencodeSnapshot) {
187
+ this.idleMs = idleMs;
188
+ this.create = create;
189
+ }
190
+ /**
191
+ * `fresh` makes a new copy (a listing refresh); `reuse` only returns an
192
+ * existing copy and fails as expired when there is none (a job).
193
+ */
194
+ async acquire(key, dbPath, mode, signal) {
195
+ let shared = mode === "fresh" ? undefined : this.current.get(key);
196
+ if (!shared) {
197
+ if (mode === "reuse") {
198
+ throw new OpencodeSnapshotError("expired", "The session list is out of date. Refresh the list and try again.");
199
+ }
200
+ let creating = mode === "fresh" ? undefined : this.pending.get(key);
201
+ if (!creating) {
202
+ creating = this.create(dbPath, { signal }).then((copy) => {
203
+ const next = { ...copy, refs: 0, retired: false };
204
+ const previous = this.current.get(key);
205
+ if (previous)
206
+ this.retire(previous);
207
+ this.current.set(key, next);
208
+ this.all.add(next);
209
+ return next;
210
+ });
211
+ this.pending.set(key, creating);
212
+ creating.then(() => this.pending.delete(key), () => this.pending.delete(key));
213
+ }
214
+ shared = await creating;
215
+ }
216
+ const lease = shared;
217
+ lease.refs++;
218
+ if (lease.timer)
219
+ clearTimeout(lease.timer);
220
+ lease.timer = undefined;
221
+ let released = false;
222
+ return {
223
+ path: lease.path,
224
+ release: async () => {
225
+ if (released)
226
+ return;
227
+ released = true;
228
+ lease.refs--;
229
+ if (lease.refs > 0)
230
+ return;
231
+ if (lease.retired)
232
+ return this.remove(lease);
233
+ lease.timer = setTimeout(() => {
234
+ if (this.current.get(key) === lease)
235
+ this.current.delete(key);
236
+ void this.remove(lease);
237
+ }, this.idleMs);
238
+ lease.timer.unref?.();
239
+ },
240
+ };
241
+ }
242
+ retire(shared) {
243
+ shared.retired = true;
244
+ if (shared.refs === 0)
245
+ void this.remove(shared);
246
+ }
247
+ async remove(shared) {
248
+ if (shared.timer)
249
+ clearTimeout(shared.timer);
250
+ if (!this.all.delete(shared))
251
+ return;
252
+ await removeSnapshotDir(shared.dir);
253
+ }
254
+ /**
255
+ * Drop the current copy for a source after a real import: a later listing
256
+ * copies afresh. A job still reading it keeps it until it releases it.
257
+ */
258
+ discard(key) {
259
+ const shared = this.current.get(key);
260
+ if (!shared)
261
+ return;
262
+ this.current.delete(key);
263
+ this.retire(shared);
264
+ }
265
+ /** Remove every copy, for server shutdown. */
266
+ async closeAll() {
267
+ this.current.clear();
268
+ await Promise.all([...this.all].map((shared) => this.remove(shared)));
269
+ }
270
+ }
271
+ export const opencodeSnapshots = new OpencodeSnapshotRegistry();
272
+ /** An unshared snapshot for one CLI or slash-command run. */
273
+ export async function acquirePrivateSnapshot(dbPath, signal) {
274
+ const copy = await createOpencodeSnapshot(dbPath, { signal });
275
+ return { path: copy.path, release: () => removeSnapshotDir(copy.dir) };
276
+ }
@@ -18,6 +18,7 @@ type Ledger = Pick<ImportLedger, "get" | "begin" | "complete"> & Partial<Pick<Im
18
18
  export interface ProfileImportOptions {
19
19
  host: MemoryHost;
20
20
  dryRun?: boolean;
21
+ signal?: AbortSignal;
21
22
  batchSize?: number;
22
23
  model?: ModelPort;
23
24
  ledger?: Ledger;
@@ -29,7 +29,11 @@ export async function importProfileFromHistory(source, options) {
29
29
  const profiles = options.profileStore ?? userProfileManager;
30
30
  let directory;
31
31
  for await (const session of source) {
32
+ if (options.signal?.aborted)
33
+ break;
32
34
  for (const unit of session.units) {
35
+ if (options.signal?.aborted)
36
+ break;
33
37
  const prompt = stripPrivateContent(unit.userPrompt).trim();
34
38
  if (!prompt ||
35
39
  isFullyPrivate(unit.userPrompt) ||
@@ -67,7 +71,7 @@ export async function importProfileFromHistory(source, options) {
67
71
  report.promptsRecorded++;
68
72
  }
69
73
  }
70
- if (options.dryRun || !directory)
74
+ if (options.dryRun || !directory || options.signal?.aborted)
71
75
  return report;
72
76
  const user = getTags(directory).user;
73
77
  if (!user.userEmail) {
@@ -77,6 +81,8 @@ export async function importProfileFromHistory(source, options) {
77
81
  }
78
82
  const model = options.model;
79
83
  while ((report.remaining = await prompts.countUnanalyzedForUserLearning()) > 0) {
84
+ if (options.signal?.aborted)
85
+ break;
80
86
  const batch = await prompts.getPromptsForUserLearning(batchSize);
81
87
  if (batch.length === 0)
82
88
  break;
@@ -13,6 +13,14 @@ export interface HistoryImportRun {
13
13
  cwd: string;
14
14
  models: HistoryImportModels;
15
15
  onProgress?: (processed: number, total: number, promptPreview: string) => void;
16
+ signal?: AbortSignal;
17
+ /** Web selection, already re-resolved against the source; `args.source` is its real path. */
18
+ selection?: {
19
+ keys: string[];
20
+ cutoff: number;
21
+ /** Shared snapshot key of an OpenCode source, reused from the listing. */
22
+ snapshotKey?: string;
23
+ };
16
24
  }
17
25
  export type HistoryImportReport = ImportReport & {
18
26
  childSessionsFolded?: number;
@@ -34,6 +42,8 @@ export declare function summarizeHistoryImportReport(report: HistoryImportReport
34
42
  unitsSkipped: number;
35
43
  unitsFailed: number;
36
44
  unitsAlreadyHandled: number;
45
+ unitsHeldBack: number;
46
+ unitsUntimed: number;
37
47
  projects: number;
38
48
  unresolved: number;
39
49
  loadErrors: number;