om-memory-system 3.4.0-next.21 → 3.4.0-next.26

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 (82) hide show
  1. package/README.md +21 -7
  2. package/dist/adapters/opencode/backfill-models.js +5 -1
  3. package/dist/adapters/opencode/backfill-startup.d.ts +6 -0
  4. package/dist/adapters/opencode/backfill-startup.js +5 -0
  5. package/dist/adapters/opencode/import-command.js +1 -0
  6. package/dist/adapters/opencode/user-prompt.d.ts +2 -1
  7. package/dist/adapters/opencode/user-prompt.js +2 -9
  8. package/dist/adapters/pi/backfill-models.d.ts +4 -0
  9. package/dist/adapters/pi/backfill-models.js +3 -0
  10. package/dist/adapters/pi/capture.d.ts +1 -1
  11. package/dist/adapters/pi/capture.js +1 -1
  12. package/dist/adapters/pi/extension.js +7 -0
  13. package/dist/adapters/pi/import-command.js +1 -0
  14. package/dist/cli/index.js +13 -1
  15. package/dist/config.d.ts +11 -4
  16. package/dist/config.js +29 -6
  17. package/dist/core/internal-prompt.d.ts +6 -0
  18. package/dist/core/internal-prompt.js +11 -0
  19. package/dist/importer/auto-backfill.d.ts +5 -0
  20. package/dist/importer/auto-backfill.js +29 -10
  21. package/dist/importer/backfill-controls.d.ts +37 -0
  22. package/dist/importer/backfill-controls.js +119 -0
  23. package/dist/importer/backfill-lock.d.ts +1 -0
  24. package/dist/importer/backfill-lock.js +2 -2
  25. package/dist/importer/backfill-model.d.ts +1 -1
  26. package/dist/importer/backfill-model.js +2 -0
  27. package/dist/importer/external-api-test.d.ts +18 -0
  28. package/dist/importer/external-api-test.js +50 -0
  29. package/dist/importer/external-backfill-models.d.ts +15 -0
  30. package/dist/importer/external-backfill-models.js +14 -0
  31. package/dist/importer/import-path-maps.d.ts +14 -0
  32. package/dist/importer/import-path-maps.js +49 -0
  33. package/dist/importer/import-progress.d.ts +27 -0
  34. package/dist/importer/import-progress.js +38 -0
  35. package/dist/importer/import-runs.d.ts +43 -0
  36. package/dist/importer/import-runs.js +141 -0
  37. package/dist/importer/import-sessions.js +9 -9
  38. package/dist/importer/importer.d.ts +2 -1
  39. package/dist/importer/importer.js +2 -2
  40. package/dist/importer/map-suggestions.d.ts +39 -0
  41. package/dist/importer/map-suggestions.js +133 -0
  42. package/dist/importer/opencode-import.d.ts +2 -1
  43. package/dist/{adapters/pi/conversation.d.ts → importer/pi-conversation.d.ts} +1 -1
  44. package/dist/importer/profile-import.js +3 -2
  45. package/dist/importer/run-import.d.ts +21 -3
  46. package/dist/importer/run-import.js +82 -2
  47. package/dist/importer/session-loader.d.ts +1 -1
  48. package/dist/importer/web-import-api.d.ts +3 -0
  49. package/dist/importer/web-import-api.js +3 -0
  50. package/dist/importer/web-import-jobs.js +1 -0
  51. package/dist/index.js +9 -4
  52. package/dist/services/ai/live-model-choice.d.ts +4 -1
  53. package/dist/services/ai/live-model-choice.js +18 -0
  54. package/dist/services/api-handlers.d.ts +6 -1
  55. package/dist/services/api-handlers.js +71 -62
  56. package/dist/services/backfill-state.d.ts +30 -0
  57. package/dist/services/backfill-state.js +47 -0
  58. package/dist/services/capture-diagnostics.d.ts +2 -8
  59. package/dist/services/capture-diagnostics.js +3 -49
  60. package/dist/services/global-config-writer.d.ts +2 -0
  61. package/dist/services/global-config-writer.js +37 -6
  62. package/dist/services/global-version.d.ts +31 -0
  63. package/dist/services/global-version.js +78 -0
  64. package/dist/services/memory-key-source.d.ts +35 -0
  65. package/dist/services/memory-key-source.js +123 -0
  66. package/dist/services/package-version.d.ts +2 -0
  67. package/dist/services/package-version.js +21 -0
  68. package/dist/services/private-path.d.ts +13 -0
  69. package/dist/services/private-path.js +54 -0
  70. package/dist/services/settings-snapshot.d.ts +17 -0
  71. package/dist/services/settings-snapshot.js +51 -14
  72. package/dist/services/turso/vector-search.d.ts +1 -1
  73. package/dist/services/turso/vector-search.js +5 -2
  74. package/dist/services/web-server.d.ts +2 -0
  75. package/dist/services/web-server.js +74 -1
  76. package/dist/web/assets/index-DvZhF1BJ.css +2 -0
  77. package/dist/web/assets/index-jqmX5i_i.js +124 -0
  78. package/dist/web/index.html +2 -2
  79. package/package.json +6 -1
  80. package/dist/web/assets/index-B-_gMSn9.js +0 -124
  81. package/dist/web/assets/index-D5ak5KsI.css +0 -2
  82. /package/dist/{adapters/pi/conversation.js → importer/pi-conversation.js} +0 -0
@@ -0,0 +1,141 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG } from "../config.js";
4
+ import { safeHealthError } from "../services/safe-health-error.js";
5
+ import { tursoConnectionManager } from "../services/turso/connection-manager.js";
6
+ import { isProcessAlive } from "./backfill-lock.js";
7
+ import { addProgressSample, estimateProgress, } from "./import-progress.js";
8
+ const dbPath = (storagePath = CONFIG.storagePath) => join(storagePath, "import-ledger.db");
9
+ async function table(storagePath) {
10
+ const db = await tursoConnectionManager.getConnection(dbPath(storagePath));
11
+ await db.run(`CREATE TABLE IF NOT EXISTS import_runs (
12
+ host TEXT PRIMARY KEY, surface TEXT, state TEXT, pid INTEGER, started_at INTEGER,
13
+ total INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0,
14
+ imported INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0,
15
+ failed INTEGER NOT NULL DEFAULT 0, error TEXT, updated_at INTEGER,
16
+ samples TEXT, paused INTEGER NOT NULL DEFAULT 0
17
+ )`);
18
+ // A row per host keeps every later statement a plain UPDATE.
19
+ await db.run("INSERT OR IGNORE INTO import_runs (host) VALUES ('pi'), ('opencode')");
20
+ return db;
21
+ }
22
+ const count = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
23
+ /** Read a host's run without creating the store; a running row whose process is gone reads as stopped. */
24
+ export async function readImportRun(host) {
25
+ if (!existsSync(dbPath()))
26
+ return null;
27
+ const db = await tursoConnectionManager.getConnection(dbPath());
28
+ const exists = await db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'import_runs'");
29
+ if (!exists)
30
+ return null;
31
+ const row = await db.get("SELECT * FROM import_runs WHERE host = ?", [
32
+ host,
33
+ ]);
34
+ if (!row)
35
+ return null;
36
+ const pid = row.pid === null ? null : Number(row.pid);
37
+ let state = (row.state ?? null);
38
+ if (state === "running" && (pid === null || !isProcessAlive(pid)))
39
+ state = "stopped";
40
+ const total = Number(row.total);
41
+ const done = Number(row.done);
42
+ const samples = row.samples ? JSON.parse(String(row.samples)) : [];
43
+ return {
44
+ host,
45
+ surface: (row.surface ?? null),
46
+ state,
47
+ pid,
48
+ startedAt: row.started_at === null ? null : Number(row.started_at),
49
+ updatedAt: row.updated_at === null ? null : Number(row.updated_at),
50
+ total,
51
+ done,
52
+ imported: Number(row.imported),
53
+ skipped: Number(row.skipped),
54
+ failed: Number(row.failed),
55
+ error: row.error === null ? null : String(row.error),
56
+ paused: Number(row.paused) === 1,
57
+ ...(state === "running"
58
+ ? estimateProgress(total, done, samples)
59
+ : {
60
+ percent: total > 0 ? Math.min(100, Math.floor((done / total) * 100)) : 0,
61
+ minutesLeft: null,
62
+ ratePerMinute: null,
63
+ }),
64
+ };
65
+ }
66
+ export async function isBackfillPaused(host) {
67
+ return (await readImportRun(host))?.paused ?? false;
68
+ }
69
+ export async function setBackfillPaused(host, paused) {
70
+ const db = await table();
71
+ await db.run("UPDATE import_runs SET paused = ?, updated_at = ? WHERE host = ?", [
72
+ paused ? 1 : 0,
73
+ Date.now(),
74
+ host,
75
+ ]);
76
+ }
77
+ /**
78
+ * Start a host's run record. Only numbers, the surface, and the PID are
79
+ * stored; `promptPreview` and all other conversation content never are.
80
+ */
81
+ export async function startImportRun(host, surface, options = {}) {
82
+ const clock = options.now ?? Date.now;
83
+ const interval = options.intervalMs ?? 1_000;
84
+ const sampleInterval = options.sampleIntervalMs ?? 15_000;
85
+ const db = await table();
86
+ const startedAt = clock();
87
+ await db.run(`UPDATE import_runs SET surface = ?, state = 'running', pid = ?, started_at = ?, total = 0,
88
+ done = 0, imported = 0, skipped = 0, failed = 0, error = NULL, updated_at = ?, samples = '[]'
89
+ WHERE host = ?`, [surface, process.pid, startedAt, startedAt, host]);
90
+ let samples = [];
91
+ let lastWrite = Number.NEGATIVE_INFINITY;
92
+ let lastSample = Number.NEGATIVE_INFINITY;
93
+ let latest = { done: 0, total: 0 };
94
+ let pauseRequested = false;
95
+ let writing = Promise.resolve();
96
+ const write = () => {
97
+ const now = clock();
98
+ lastWrite = now;
99
+ // Sample less often than we write, so the window spans minutes, not seconds.
100
+ if (now - lastSample >= sampleInterval) {
101
+ lastSample = now;
102
+ samples = addProgressSample(samples, { at: now, done: latest.done });
103
+ }
104
+ const snapshot = { ...latest, samples: JSON.stringify(samples) };
105
+ writing = writing
106
+ .then(async () => {
107
+ await db.run("UPDATE import_runs SET total = ?, done = ?, samples = ?, updated_at = ? WHERE host = ?", [count(snapshot.total), count(snapshot.done), snapshot.samples, now, host]);
108
+ const row = await db.get("SELECT paused FROM import_runs WHERE host = ?", [host]);
109
+ if (Number(row?.paused) === 1 && !pauseRequested) {
110
+ pauseRequested = true;
111
+ options.onPause?.();
112
+ }
113
+ })
114
+ .catch(() => { });
115
+ };
116
+ return {
117
+ progress(done, total) {
118
+ latest = { done, total };
119
+ if (clock() - lastWrite >= interval)
120
+ write();
121
+ },
122
+ get pauseRequested() {
123
+ return pauseRequested;
124
+ },
125
+ async finish(state, counts, error) {
126
+ await writing;
127
+ await db.run(`UPDATE import_runs SET state = ?, total = ?, done = ?, imported = ?, skipped = ?,
128
+ failed = ?, error = ?, updated_at = ? WHERE host = ?`, [
129
+ state,
130
+ count(counts.total ?? latest.total),
131
+ count(counts.done ?? latest.done),
132
+ count(counts.imported ?? 0),
133
+ count(counts.skipped ?? 0),
134
+ count(counts.failed ?? 0),
135
+ error == null ? null : safeHealthError(error, [CONFIG.memoryApiKey]).slice(0, 500),
136
+ clock(),
137
+ host,
138
+ ]);
139
+ },
140
+ };
141
+ }
@@ -65,14 +65,11 @@ export async function matchImportSessions(identity, options, snapshotMode) {
65
65
  const projectTag = options.scope === "current-project"
66
66
  ? projectFilterTag(resolve(options.cwd, options.project ?? "."))
67
67
  : null;
68
- // Map targets resolve against the working directory, as `runHistoryImport` does.
69
- const rows = await readRows(identity, {
70
- ...options,
71
- pathMaps: options.pathMaps.map((map) => ({
72
- from: map.from,
73
- to: resolve(options.cwd, map.to),
74
- })),
75
- }, snapshotMode);
68
+ // Saved maps plus the request's own, as `runHistoryImport` uses them.
69
+ // The revision hashes the same merged maps, so a saved-map change is seen as stale.
70
+ const { runPathMaps } = await import("./import-path-maps.js");
71
+ const merged = { ...options, pathMaps: await runPathMaps(options.pathMaps, options.cwd) };
72
+ const rows = await readRows(identity, merged, snapshotMode);
76
73
  const unresolved = rows.filter((row) => row.directory === null);
77
74
  const matching = rows
78
75
  .filter((row) => row.directory !== null)
@@ -82,7 +79,7 @@ export async function matchImportSessions(identity, options, snapshotMode) {
82
79
  identity,
83
80
  matching,
84
81
  unresolved,
85
- revision: revisionOf(identity, options, projectTag, matching.map((row) => row.key)),
82
+ revision: revisionOf(identity, merged, projectTag, matching.map((row) => row.key)),
86
83
  };
87
84
  }
88
85
  /** Check a session-list request body from the page. */
@@ -136,6 +133,9 @@ export async function listImportSessions(request, options) {
136
133
  // A refresh takes a fresh copy of a live database; paging reuses the current one.
137
134
  const listedAt = Date.now();
138
135
  const matched = await matchImportSessions(identity, options, request.refresh ? "fresh" : "any");
136
+ // Feed the Directory maps list; a failure here must not break the listing.
137
+ const { recordUnresolvedDirectories, summarizeUnresolvedDirectories } = await import("../services/backfill-state.js");
138
+ await recordUnresolvedDirectories(options.host, summarizeUnresolvedDirectories(matched.unresolved.map((row) => ({ directory: row.recordedDirectory })))).catch(() => { });
139
139
  const rows = [
140
140
  ...matched.matching.map((row) => ({ ...row, selectable: true })),
141
141
  ...(options.scope === "all-projects"
@@ -87,7 +87,8 @@ export interface ImportReport {
87
87
  export interface ImporterDeps {
88
88
  loadSession: (file: string) => LoadedPiSession;
89
89
  provider: CaptureSummaryProvider;
90
- onProgress?: (processed: number, total: number, promptPreview: string) => void;
90
+ /** `alreadyHandled`: units found already done so far, which need no model call. */
91
+ onProgress?: (processed: number, total: number, promptPreview: string, alreadyHandled?: number) => void;
91
92
  signal?: AbortSignal;
92
93
  ledger?: PiImportLedger;
93
94
  profile?: {
@@ -6,7 +6,7 @@ import { extractScopeFromContainerTag } from "../services/memory-scope.js";
6
6
  import { getTags } from "../services/tags.js";
7
7
  import { tursoConnectionManager } from "../services/turso/connection-manager.js";
8
8
  import { tursoShardManager } from "../services/turso/shard-manager.js";
9
- import { extractPiConversationWindows, } from "../adapters/pi/conversation.js";
9
+ import { extractPiConversationWindows, } from "./pi-conversation.js";
10
10
  import { discoverPiSessions } from "./discovery.js";
11
11
  import { resolveImportProject } from "./import-project.js";
12
12
  import { PiImportLedger, importLedgerDbPath } from "./ledger.js";
@@ -149,7 +149,7 @@ export async function importHistorySource(source, host, deps, filters, report) {
149
149
  const { sessionId, directory, sourceFile, hash, window } = candidate;
150
150
  const key = buildImportKey(sessionId, window, host);
151
151
  processed++;
152
- deps.onProgress?.(processed, report.unitsTotal, preview(window.userPrompt));
152
+ deps.onProgress?.(processed, report.unitsTotal, preview(window.userPrompt), report.unitsAlreadyHandled);
153
153
  const unit = {
154
154
  key,
155
155
  sessionId: sessionId,
@@ -0,0 +1,39 @@
1
+ import type { UnresolvedDirectory } from "../services/backfill-state.js";
2
+ export interface MapSuggestionContext {
3
+ /** Existing project directories the user has history for; their parents are searched too. */
4
+ knownProjects?: readonly string[];
5
+ /** OpenCode's recorded project worktree for a session directory, read without writing. */
6
+ opencodeWorktree?: (directory: string) => string | null | undefined;
7
+ /** The user's home folder; the search never climbs above it. Defaults to `homedir()`. */
8
+ home?: string;
9
+ /** Folder listings already read in this request. */
10
+ cache?: Map<string, string[]>;
11
+ }
12
+ /**
13
+ * Suggest an existing target for a directory that no longer exists: first the
14
+ * main repository of a deleted worktree, by the longest leading part of the
15
+ * directory's name or one of its parents' names, then OpenCode's recorded
16
+ * project worktree. Returns null when there is no candidate.
17
+ */
18
+ export declare function suggestMapTarget(missing: string, context?: MapSuggestionContext): string | null;
19
+ export interface SuggestedDirectory extends UnresolvedDirectory {
20
+ suggestion: string | null;
21
+ }
22
+ export declare function suggestMapTargets(directories: readonly UnresolvedDirectory[], context?: MapSuggestionContext): SuggestedDirectory[];
23
+ /**
24
+ * OpenCode's recorded project worktree for each session directory, through
25
+ * the importer's read-only reader. OpenCode's database is never written.
26
+ */
27
+ export declare function readOpencodeWorktrees(dbPath: string): Promise<Map<string, string>>;
28
+ /**
29
+ * The Directory maps list: saved maps and each host's unresolved directories
30
+ * from its latest run, with suggestions. Paths and counts only.
31
+ */
32
+ export declare function directoryMapsView(options?: {
33
+ opencodeDbPath?: string;
34
+ knownProjects?: readonly string[];
35
+ }): Promise<{
36
+ saved: import("./importer.js").ImportPathMap[];
37
+ pi: SuggestedDirectory[];
38
+ opencode: SuggestedDirectory[];
39
+ }>;
@@ -0,0 +1,133 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, parse } from "node:path";
4
+ const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
5
+ function isDirectory(path) {
6
+ try {
7
+ return statSync(path).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function isProject(path) {
14
+ return PROJECT_MARKERS.some((marker) => existsSync(join(path, marker)));
15
+ }
16
+ function childDirectories(parent, cache) {
17
+ const cached = cache?.get(parent);
18
+ if (cached)
19
+ return cached;
20
+ let children;
21
+ try {
22
+ children = readdirSync(parent, { withFileTypes: true })
23
+ .filter((entry) => entry.isDirectory())
24
+ .map((entry) => join(parent, entry.name));
25
+ }
26
+ catch {
27
+ children = [];
28
+ }
29
+ cache?.set(parent, children);
30
+ return children;
31
+ }
32
+ /** `app` is a leading part of `app`, `app-feat-x`, and `app-feat-x-2`, not of `apple`. */
33
+ function isLeadingPart(candidate, name) {
34
+ return name === candidate || name.startsWith(`${candidate}-`);
35
+ }
36
+ /**
37
+ * Suggest an existing target for a directory that no longer exists: first the
38
+ * main repository of a deleted worktree, by the longest leading part of the
39
+ * directory's name or one of its parents' names, then OpenCode's recorded
40
+ * project worktree. Returns null when there is no candidate.
41
+ */
42
+ export function suggestMapTarget(missing, context = {}) {
43
+ const searchRoots = new Set();
44
+ for (const project of context.knownProjects ?? []) {
45
+ if (!isDirectory(project))
46
+ continue;
47
+ searchRoots.add(dirname(project));
48
+ }
49
+ // Never list the filesystem root or the folder that holds every home folder,
50
+ // and stop at the home folder itself: broad scans suggest unrelated folders.
51
+ const home = context.home ?? homedir();
52
+ const blocked = new Set([parse(missing).root, dirname(home)]);
53
+ let ancestor = missing;
54
+ while (dirname(ancestor) !== ancestor && ancestor !== home) {
55
+ const name = basename(ancestor);
56
+ const parents = new Set([dirname(ancestor), ...searchRoots]);
57
+ let best = null;
58
+ for (const parent of parents) {
59
+ if (blocked.has(parent))
60
+ continue;
61
+ for (const candidate of childDirectories(parent, context.cache)) {
62
+ if (candidate === ancestor || !isLeadingPart(basename(candidate), name))
63
+ continue;
64
+ if (!isProject(candidate))
65
+ continue;
66
+ if (!best || basename(candidate).length > basename(best).length)
67
+ best = candidate;
68
+ }
69
+ }
70
+ if (best)
71
+ return best;
72
+ ancestor = dirname(ancestor);
73
+ }
74
+ const worktree = context.opencodeWorktree?.(missing);
75
+ if (worktree && worktree !== "/" && isDirectory(worktree))
76
+ return worktree;
77
+ return null;
78
+ }
79
+ export function suggestMapTargets(directories, context = {}) {
80
+ const withCache = { ...context, cache: context.cache ?? new Map() };
81
+ return directories.map((item) => ({
82
+ ...item,
83
+ suggestion: suggestMapTarget(item.directory, withCache),
84
+ }));
85
+ }
86
+ /**
87
+ * OpenCode's recorded project worktree for each session directory, through
88
+ * the importer's read-only reader. OpenCode's database is never written.
89
+ */
90
+ export async function readOpencodeWorktrees(dbPath) {
91
+ const { openOpencodeHistory } = await import("./opencode-reader.js");
92
+ const history = await openOpencodeHistory(dbPath);
93
+ try {
94
+ const worktrees = new Map();
95
+ for (const session of history.sessions) {
96
+ if (session.recordedDirectory && session.projectWorktree) {
97
+ worktrees.set(session.recordedDirectory, session.projectWorktree);
98
+ }
99
+ }
100
+ return worktrees;
101
+ }
102
+ finally {
103
+ await history.close();
104
+ }
105
+ }
106
+ /**
107
+ * The Directory maps list: saved maps and each host's unresolved directories
108
+ * from its latest run, with suggestions. Paths and counts only.
109
+ */
110
+ export async function directoryMapsView(options = {}) {
111
+ const { CONFIG } = await import("../config.js");
112
+ const { readUnresolvedDirectories } = await import("../services/backfill-state.js");
113
+ const { DEFAULT_OPENCODE_DB } = await import("./opencode-reader.js");
114
+ const saved = new Set(CONFIG.importPathMaps.map((map) => map.from));
115
+ let worktrees = new Map();
116
+ const dbPath = options.opencodeDbPath ?? DEFAULT_OPENCODE_DB;
117
+ if (existsSync(dbPath)) {
118
+ worktrees = await readOpencodeWorktrees(dbPath).catch(() => new Map());
119
+ }
120
+ const knownProjects = [
121
+ ...(options.knownProjects ?? []),
122
+ ...CONFIG.importPathMaps.map((map) => map.to),
123
+ ...worktrees.values(),
124
+ ];
125
+ const host = async (name) => {
126
+ const pending = (await readUnresolvedDirectories(name)).filter((item) => !saved.has(item.directory));
127
+ return suggestMapTargets(pending, {
128
+ knownProjects,
129
+ ...(name === "opencode" ? { opencodeWorktree: (dir) => worktrees.get(dir) } : {}),
130
+ });
131
+ };
132
+ return { saved: CONFIG.importPathMaps, pi: await host("pi"), opencode: await host("opencode") };
133
+ }
@@ -26,7 +26,8 @@ export interface OpencodeImportOptions {
26
26
  profileBatch?: number;
27
27
  provider?: CaptureSummaryProvider;
28
28
  profileModel?: ModelPort;
29
- onProgress?: (processed: number, total: number, promptPreview: string) => void;
29
+ /** `alreadyHandled`: units found already done so far, which need no model call. */
30
+ onProgress?: (processed: number, total: number, promptPreview: string, alreadyHandled?: number) => void;
30
31
  signal?: AbortSignal;
31
32
  }
32
33
  export interface OpencodeImportReport extends ImportReport {
@@ -1,4 +1,4 @@
1
- import type { CaptureConversation } from "../../core/host.js";
1
+ import type { CaptureConversation } from "../core/host.js";
2
2
  /**
3
3
  * Minimal structural view of Pi session entries (see Pi session-format docs).
4
4
  * Kept structural so the normaliser stays testable without importing Pi types.
@@ -1,4 +1,4 @@
1
- import { isInternalPrompt } from "../adapters/opencode/user-prompt.js";
1
+ import { isStructuredSummaryPromptMessage } from "../core/internal-prompt.js";
2
2
  import { analyzeProfile } from "../core/profile-analysis.js";
3
3
  import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
4
4
  import { getTags } from "../services/tags.js";
@@ -37,7 +37,8 @@ export async function importProfileFromHistory(source, options) {
37
37
  const prompt = stripPrivateContent(unit.userPrompt).trim();
38
38
  if (!prompt ||
39
39
  isFullyPrivate(unit.userPrompt) ||
40
- isInternalPrompt(session.sessionId, prompt)) {
40
+ // OpenCode history already leaves out omms's own capture sessions by title.
41
+ isStructuredSummaryPromptMessage(prompt)) {
41
42
  continue;
42
43
  }
43
44
  const key = buildImportKey(session.sessionId, unit, options.host);
@@ -1,8 +1,9 @@
1
1
  import type { CaptureSummaryProvider } from "../core/host.js";
2
2
  import type { ModelPort } from "../core/profile-analysis.js";
3
3
  import type { HistoryImportArgs, ImportHost } from "./import-args.js";
4
- import type { ImportReport } from "./importer.js";
4
+ import type { ImportPathMap, ImportReport } from "./importer.js";
5
5
  import type { UnresolvedProject } from "./opencode-project.js";
6
+ import type { ImportSurface } from "./import-runs.js";
6
7
  /** The two model roles an import uses; absent in a dry run or when the step is skipped. */
7
8
  export interface HistoryImportModels {
8
9
  capture?: CaptureSummaryProvider;
@@ -12,8 +13,17 @@ export interface HistoryImportRun {
12
13
  /** Directory that relative paths and the default project resolve against. */
13
14
  cwd: string;
14
15
  models: HistoryImportModels;
15
- onProgress?: (processed: number, total: number, promptPreview: string) => void;
16
+ /** `alreadyHandled`: units found already done so far, which need no model call. */
17
+ onProgress?: (processed: number, total: number, promptPreview: string, alreadyHandled?: number) => void;
16
18
  signal?: AbortSignal;
19
+ /** Record progress and take the host's lock for a run that calls models. */
20
+ track?: {
21
+ surface: ImportSurface;
22
+ lockHeld?: boolean;
23
+ expectedTotal?: number;
24
+ };
25
+ /** Saved directory maps; defaults to the global `importPathMaps`. */
26
+ savedPathMaps?: readonly ImportPathMap[];
17
27
  /** Web selection, already re-resolved against the source; `args.source` is its real path. */
18
28
  selection?: {
19
29
  keys: string[];
@@ -26,7 +36,15 @@ export type HistoryImportReport = ImportReport & {
26
36
  childSessionsFolded?: number;
27
37
  unresolvedProjects?: UnresolvedProject[];
28
38
  };
29
- /** Run one host's history import with already-parsed, validated arguments. */
39
+ /** Another process or surface holds this host's import lock. */
40
+ export declare class ImportAlreadyRunningError extends Error {
41
+ constructor(host: ImportHost);
42
+ }
43
+ /**
44
+ * Run one host's history import with already-parsed, validated arguments.
45
+ * With `track`, a run that calls models takes the host's cross-process lock
46
+ * and records its progress; a dry run never does either.
47
+ */
30
48
  export declare function runHistoryImport(host: ImportHost, args: HistoryImportArgs, run: HistoryImportRun): Promise<HistoryImportReport>;
31
49
  /** The same plain-text report for every host and surface. */
32
50
  export declare function formatHistoryImportReport(host: ImportHost, report: HistoryImportReport, model?: string): string;
@@ -4,11 +4,91 @@ const dryRunCapture = {
4
4
  throw new Error("A dry-run cannot call the capture model");
5
5
  },
6
6
  };
7
- /** Run one host's history import with already-parsed, validated arguments. */
7
+ /** Another process or surface holds this host's import lock. */
8
+ export class ImportAlreadyRunningError extends Error {
9
+ constructor(host) {
10
+ super(`${host === "pi" ? "A Pi" : "An OpenCode"} import is already running`);
11
+ this.name = "ImportAlreadyRunningError";
12
+ }
13
+ }
14
+ /**
15
+ * Run one host's history import with already-parsed, validated arguments.
16
+ * With `track`, a run that calls models takes the host's cross-process lock
17
+ * and records its progress; a dry run never does either.
18
+ */
8
19
  export async function runHistoryImport(host, args, run) {
20
+ if (!run.track || args.dryRun)
21
+ return runUntracked(host, args, run);
22
+ let release = null;
23
+ if (!run.track.lockHeld) {
24
+ const { tryAcquireBackfillLock } = await import("./backfill-lock.js");
25
+ release = await tryAcquireBackfillLock(host);
26
+ if (!release)
27
+ throw new ImportAlreadyRunningError(host);
28
+ }
29
+ try {
30
+ const { startImportRun } = await import("./import-runs.js");
31
+ const { workProgress } = await import("./import-progress.js");
32
+ const controller = new AbortController();
33
+ const onAbort = () => controller.abort();
34
+ run.signal?.addEventListener("abort", onAbort, { once: true });
35
+ if (run.signal?.aborted)
36
+ controller.abort();
37
+ const recorder = await startImportRun(host, run.track.surface, {
38
+ onPause: () => controller.abort(),
39
+ });
40
+ try {
41
+ const report = await runUntracked(host, args, {
42
+ ...run,
43
+ signal: controller.signal,
44
+ onProgress: (processed, total, preview, alreadyHandled = 0) => {
45
+ // Units already in the ledger need no model call: leave them out of
46
+ // the progress so the bar and the time left describe real work.
47
+ const work = workProgress(processed, total, alreadyHandled, run.track.expectedTotal);
48
+ recorder.progress(work.done, work.total);
49
+ run.onProgress?.(processed, total, preview, alreadyHandled);
50
+ },
51
+ });
52
+ const handled = report.unitsImported + report.unitsSkipped + report.unitsFailed;
53
+ await recorder.finish(recorder.pauseRequested ? "paused" : controller.signal.aborted ? "stopped" : "done", {
54
+ // The total stays the one progress last reported; a real run has no
55
+ // "would import" count of its own.
56
+ done: handled,
57
+ imported: report.unitsImported,
58
+ skipped: report.unitsSkipped,
59
+ failed: report.unitsFailed,
60
+ });
61
+ await recordUnresolved(host, report);
62
+ return report;
63
+ }
64
+ catch (error) {
65
+ await recorder
66
+ .finish(recorder.pauseRequested ? "paused" : controller.signal.aborted ? "stopped" : "failed", {}, error)
67
+ .catch(() => { });
68
+ throw error;
69
+ }
70
+ finally {
71
+ run.signal?.removeEventListener("abort", onAbort);
72
+ }
73
+ }
74
+ finally {
75
+ await release?.();
76
+ }
77
+ }
78
+ async function recordUnresolved(host, report) {
79
+ try {
80
+ const { recordUnresolvedDirectories, unresolvedDirectoriesOf } = await import("../services/backfill-state.js");
81
+ await recordUnresolvedDirectories(host, unresolvedDirectoriesOf(report));
82
+ }
83
+ catch {
84
+ // The list is a convenience for the Settings page; the import itself succeeded.
85
+ }
86
+ }
87
+ async function runUntracked(host, args, run) {
9
88
  const at = (path) => resolve(run.cwd, path);
10
89
  const project = args.scope === "current-project" ? at(args.project ?? ".") : undefined;
11
- const pathMaps = args.pathMaps.map((map) => ({ from: map.from, to: at(map.to) }));
90
+ const { runPathMaps } = await import("./import-path-maps.js");
91
+ const pathMaps = await runPathMaps(args.pathMaps, run.cwd, run.savedPathMaps);
12
92
  const capture = run.models.capture ?? dryRunCapture;
13
93
  if (host === "opencode") {
14
94
  const { importOpencodeHistory } = await import("./opencode-import.js");
@@ -1,4 +1,4 @@
1
- import type { PiSessionEntry } from "../adapters/pi/conversation.js";
1
+ import type { PiSessionEntry } from "./pi-conversation.js";
2
2
  /**
3
3
  * Loads a Pi session file for import through Pi's own exported session model
4
4
  * (`SessionManager.open`), which handles parsing and legacy-version migration
@@ -6,3 +6,6 @@ export { importReadiness } from "./import-readiness.js";
6
6
  export { browseImportSources, validateImportSource } from "./import-sources.js";
7
7
  export { listImportSessions, validateSessionListRequest } from "./import-sessions.js";
8
8
  export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
9
+ export { BackfillControls } from "./backfill-controls.js";
10
+ export { directoryMapsView } from "./map-suggestions.js";
11
+ export { testExternalApi } from "./external-api-test.js";
@@ -6,3 +6,6 @@ export { importReadiness } from "./import-readiness.js";
6
6
  export { browseImportSources, validateImportSource } from "./import-sources.js";
7
7
  export { listImportSessions, validateSessionListRequest } from "./import-sessions.js";
8
8
  export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
9
+ export { BackfillControls } from "./backfill-controls.js";
10
+ export { directoryMapsView } from "./map-suggestions.js";
11
+ export { testExternalApi } from "./external-api-test.js";
@@ -187,6 +187,7 @@ export class SettingsImportJobs {
187
187
  cwd: directory,
188
188
  models,
189
189
  signal: controller.signal,
190
+ track: { surface: "web" },
190
191
  selection: {
191
192
  keys: selection.keys,
192
193
  cutoff: selection.cutoff,
package/dist/index.js CHANGED
@@ -231,15 +231,20 @@ export const OmmsPlugin = async (ctx) => {
231
231
  catch (error) {
232
232
  log("Failed to initialize opencode provider state", { error: String(error) });
233
233
  }
234
+ const configModel = async () => {
235
+ const response = await ctx.client.config.get();
236
+ return unwrapSdkData(response)?.model ?? null;
237
+ };
238
+ // Run now on a Settings page served by this process can use OpenCode's signed-in models.
239
+ void import("./adapters/opencode/backfill-startup.js")
240
+ .then(({ registerOpencodeBackfillModels }) => registerOpencodeBackfillModels({ connected, directory, configModel }))
241
+ .catch(() => { });
234
242
  if (CONFIG.autoBackfill && process.env.OMMS_DISABLE_AUTO_BACKFILL !== "1" && isConfigured()) {
235
243
  void import("./adapters/opencode/backfill-startup.js")
236
244
  .then(({ startOpencodeBackfill }) => startOpencodeBackfill({
237
245
  connected,
238
246
  directory,
239
- configModel: async () => {
240
- const response = await ctx.client.config.get();
241
- return unwrapSdkData(response)?.model ?? null;
242
- },
247
+ configModel,
243
248
  notify: (message) => {
244
249
  void ctx.client.tui
245
250
  ?.showToast({
@@ -14,7 +14,8 @@ export interface AutoCaptureProviderRuntimeConfig {
14
14
  /**
15
15
  * Where live capture and profile learning send model calls, the same on both hosts:
16
16
  * - `opencode`: the configured host model (`opencodeModel`/`piModel`; "inherit" = session model)
17
- * - `manual`: no host model, so the external API (`memoryModel`/`memoryApiUrl`/`memoryApiKey`)
17
+ * - `manual`: the external API (`memoryModel`/`memoryApiUrl`/`memoryApiKey`), because the
18
+ * host model is "external" or no host model is set
18
19
  * - `session`: neither is set, so the session's own model through the host's sign-in
19
20
  */
20
21
  export type AutoCaptureProviderStatus = {
@@ -50,3 +51,5 @@ export declare function resolvePiLiveModel(config: AutoCaptureProviderRuntimeCon
50
51
  }): PiLiveModelChoice;
51
52
  /** True when the external API is fully configured and can serve as a fallback. */
52
53
  export declare function isExternalModelReady(config: AutoCaptureProviderRuntimeConfig): boolean;
54
+ /** Why the external API cannot be used as a host's chosen model, or an empty list. */
55
+ export declare function externalModelIssues(config: AutoCaptureProviderRuntimeConfig): string[];