om-memory-system 3.4.0-next.23 → 3.4.0-next.27

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 (122) hide show
  1. package/README.md +21 -7
  2. package/dist/adapters/opencode/auto-capture-summary.js +1 -1
  3. package/dist/adapters/opencode/backfill-models.d.ts +1 -1
  4. package/dist/adapters/opencode/backfill-models.js +6 -2
  5. package/dist/adapters/opencode/backfill-startup.d.ts +8 -0
  6. package/dist/adapters/opencode/backfill-startup.js +19 -0
  7. package/dist/adapters/opencode/import-command.d.ts +3 -3
  8. package/dist/adapters/opencode/import-command.js +4 -3
  9. package/dist/adapters/opencode/internal-capture-sessions.d.ts +4 -0
  10. package/dist/{services/ai → adapters/opencode}/internal-capture-sessions.js +1 -14
  11. package/dist/{services/ai → adapters/opencode}/opencode-import-models.js +1 -1
  12. package/dist/{services/user-memory-learning.d.ts → adapters/opencode/profile-learning.d.ts} +1 -1
  13. package/dist/{services/user-memory-learning.js → adapters/opencode/profile-learning.js} +14 -14
  14. package/dist/{services/ai → adapters/opencode}/profile-llm-client.js +1 -1
  15. package/dist/adapters/opencode/profile-model.d.ts +23 -0
  16. package/dist/adapters/opencode/profile-model.js +117 -0
  17. package/dist/adapters/opencode/user-prompt.d.ts +2 -1
  18. package/dist/adapters/opencode/user-prompt.js +3 -10
  19. package/dist/adapters/pi/backfill-models.d.ts +4 -0
  20. package/dist/adapters/pi/backfill-models.js +3 -0
  21. package/dist/adapters/pi/capture.d.ts +1 -1
  22. package/dist/adapters/pi/capture.js +1 -1
  23. package/dist/adapters/pi/extension.js +7 -0
  24. package/dist/adapters/pi/import-command.js +1 -0
  25. package/dist/cli/index.js +13 -1
  26. package/dist/config.d.ts +11 -4
  27. package/dist/config.js +29 -6
  28. package/dist/core/internal-prompt.d.ts +6 -0
  29. package/dist/core/internal-prompt.js +11 -0
  30. package/dist/core/profile-analysis.d.ts +3 -0
  31. package/dist/importer/auto-backfill.d.ts +5 -0
  32. package/dist/importer/auto-backfill.js +29 -10
  33. package/dist/importer/backfill-controls.d.ts +53 -0
  34. package/dist/importer/backfill-controls.js +126 -0
  35. package/dist/importer/backfill-lock.d.ts +1 -0
  36. package/dist/importer/backfill-lock.js +2 -2
  37. package/dist/importer/backfill-model.d.ts +1 -1
  38. package/dist/importer/backfill-model.js +2 -0
  39. package/dist/importer/external-api-test.d.ts +18 -0
  40. package/dist/importer/external-api-test.js +50 -0
  41. package/dist/importer/external-backfill-models.d.ts +15 -0
  42. package/dist/importer/external-backfill-models.js +14 -0
  43. package/dist/importer/import-path-maps.d.ts +14 -0
  44. package/dist/importer/import-path-maps.js +49 -0
  45. package/dist/importer/import-progress.d.ts +27 -0
  46. package/dist/importer/import-progress.js +38 -0
  47. package/dist/importer/import-readiness.js +1 -1
  48. package/dist/importer/import-runs.d.ts +43 -0
  49. package/dist/importer/import-runs.js +141 -0
  50. package/dist/importer/import-sessions.js +9 -9
  51. package/dist/importer/importer.d.ts +2 -1
  52. package/dist/importer/importer.js +2 -2
  53. package/dist/importer/map-suggestions.d.ts +39 -0
  54. package/dist/importer/map-suggestions.js +133 -0
  55. package/dist/importer/opencode-import.d.ts +2 -1
  56. package/dist/{services/ai/internal-capture-sessions.d.ts → importer/opencode-internal-sessions.d.ts} +0 -3
  57. package/dist/importer/opencode-internal-sessions.js +14 -0
  58. package/dist/importer/opencode-reader.js +1 -1
  59. package/dist/{adapters/pi/conversation.d.ts → importer/pi-conversation.d.ts} +1 -1
  60. package/dist/importer/profile-import.js +3 -2
  61. package/dist/importer/run-import.d.ts +21 -3
  62. package/dist/importer/run-import.js +82 -2
  63. package/dist/importer/session-loader.d.ts +1 -1
  64. package/dist/importer/settings-health.js +8 -1
  65. package/dist/{services → importer}/settings-models.d.ts +4 -5
  66. package/dist/{services → importer}/settings-models.js +24 -13
  67. package/dist/importer/web-import-api.d.ts +3 -0
  68. package/dist/importer/web-import-api.js +3 -0
  69. package/dist/importer/web-import-jobs.js +5 -5
  70. package/dist/index.d.ts +1 -1
  71. package/dist/index.js +22 -9
  72. package/dist/services/ai/live-model-choice.d.ts +4 -1
  73. package/dist/services/ai/live-model-choice.js +18 -0
  74. package/dist/services/api-handlers.d.ts +6 -1
  75. package/dist/services/api-handlers.js +71 -62
  76. package/dist/services/backfill-state.d.ts +30 -0
  77. package/dist/services/backfill-state.js +47 -0
  78. package/dist/services/capture-diagnostics.d.ts +2 -8
  79. package/dist/services/capture-diagnostics.js +3 -49
  80. package/dist/services/global-config-writer.d.ts +2 -0
  81. package/dist/services/global-config-writer.js +37 -6
  82. package/dist/services/global-version.d.ts +31 -0
  83. package/dist/services/global-version.js +78 -0
  84. package/dist/services/memory-key-source.d.ts +35 -0
  85. package/dist/services/memory-key-source.js +123 -0
  86. package/dist/services/package-version.d.ts +2 -0
  87. package/dist/services/package-version.js +21 -0
  88. package/dist/services/private-path.d.ts +13 -0
  89. package/dist/services/private-path.js +54 -0
  90. package/dist/services/settings-snapshot.d.ts +17 -0
  91. package/dist/services/settings-snapshot.js +51 -14
  92. package/dist/services/turso/vector-search.d.ts +1 -1
  93. package/dist/services/turso/vector-search.js +5 -2
  94. package/dist/services/user-profile/ai-cleanup.d.ts +0 -16
  95. package/dist/services/user-profile/ai-cleanup.js +17 -91
  96. package/dist/services/user-profile/profile-model.d.ts +8 -0
  97. package/dist/services/user-profile/profile-model.js +31 -0
  98. package/dist/services/user-profile/user-profile-manager.d.ts +1 -1
  99. package/dist/services/user-profile/user-profile-manager.js +20 -74
  100. package/dist/services/web-server.d.ts +2 -0
  101. package/dist/services/web-server.js +75 -2
  102. package/dist/shared/api/schemas.d.ts +1 -1
  103. package/dist/v2/plugin.js +1 -1
  104. package/dist/web/assets/index-DvZhF1BJ.css +2 -0
  105. package/dist/web/assets/index-jqmX5i_i.js +124 -0
  106. package/dist/web/index.html +2 -2
  107. package/package.json +6 -1
  108. package/dist/web/assets/index-B-_gMSn9.js +0 -124
  109. package/dist/web/assets/index-D5ak5KsI.css +0 -2
  110. /package/dist/{services/ai → adapters/opencode}/opencode-diagnostics.d.ts +0 -0
  111. /package/dist/{services/ai → adapters/opencode}/opencode-diagnostics.js +0 -0
  112. /package/dist/{services/ai → adapters/opencode}/opencode-host-config.d.ts +0 -0
  113. /package/dist/{services/ai → adapters/opencode}/opencode-host-config.js +0 -0
  114. /package/dist/{services/ai → adapters/opencode}/opencode-import-models.d.ts +0 -0
  115. /package/dist/{services/ai → adapters/opencode}/opencode-provider-loader.d.ts +0 -0
  116. /package/dist/{services/ai → adapters/opencode}/opencode-provider-loader.js +0 -0
  117. /package/dist/{services/ai → adapters/opencode}/opencode-provider.d.ts +0 -0
  118. /package/dist/{services/ai → adapters/opencode}/opencode-provider.js +0 -0
  119. /package/dist/{services/ai → adapters/opencode}/opencode-sdk-client.d.ts +0 -0
  120. /package/dist/{services/ai → adapters/opencode}/opencode-sdk-client.js +0 -0
  121. /package/dist/{services/ai → adapters/opencode}/profile-llm-client.d.ts +0 -0
  122. /package/dist/{adapters/pi/conversation.js → importer/pi-conversation.js} +0 -0
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CONFIG } from "../config.js";
3
+ import { safeHealthError } from "../services/safe-health-error.js";
4
+ const OUTPUT_LIMIT = 64;
5
+ const TIMEOUT_MS = 20_000;
6
+ /** Provider-specific output caps; Anthropic-style providers read `maxTokens`. */
7
+ function outputLimitParams(provider) {
8
+ if (provider === "openai-chat" || provider === "orcarouter")
9
+ return { max_tokens: OUTPUT_LIMIT };
10
+ if (provider === "openai-responses")
11
+ return { max_output_tokens: OUTPUT_LIMIT };
12
+ return {};
13
+ }
14
+ /**
15
+ * One small call to the saved external API from this process, with a small
16
+ * output limit. The error never contains the key.
17
+ */
18
+ export async function testExternalApi(config = CONFIG, createProvider) {
19
+ const secrets = [config.memoryApiKey];
20
+ try {
21
+ const { buildMemoryProviderConfig } = await import("../services/ai/provider-config.js");
22
+ const base = buildMemoryProviderConfig(config, {
23
+ maxIterations: 1,
24
+ iterationTimeout: TIMEOUT_MS,
25
+ });
26
+ const providerConfig = {
27
+ ...base,
28
+ maxTokens: OUTPUT_LIMIT,
29
+ extraParams: { ...base.extraParams, ...outputLimitParams(config.memoryProvider) },
30
+ };
31
+ const create = createProvider ??
32
+ (await import("../services/ai/ai-provider-factory.js")).AIProviderFactory.createProvider;
33
+ const provider = create(config.memoryProvider, providerConfig);
34
+ const tool = {
35
+ type: "function",
36
+ function: {
37
+ name: "acknowledge",
38
+ description: "Confirm the connection works",
39
+ parameters: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
40
+ },
41
+ };
42
+ const result = await provider.executeToolCall("You are testing a connection. Call the acknowledge tool with ok set to true.", "Acknowledge.", tool, `external-api-test-${randomUUID()}`);
43
+ if (!result.success)
44
+ throw new Error(result.error ?? "The external API call failed");
45
+ return { ok: true, model: `${config.memoryProvider}/${config.memoryModel ?? "default"}` };
46
+ }
47
+ catch (error) {
48
+ return { ok: false, error: safeHealthError(error, secrets).slice(0, 500) };
49
+ }
50
+ }
@@ -0,0 +1,15 @@
1
+ import type { BackfillHost } from "./backfill-model.js";
2
+ import type { SelectedImportModel } from "./model-selection.js";
3
+ import type { HistoryImportModels } from "./run-import.js";
4
+ interface ExternalConfig {
5
+ memoryProvider?: string;
6
+ memoryModel?: string;
7
+ memoryApiUrl?: string;
8
+ memoryApiKey?: string;
9
+ }
10
+ /** A backfill set to `external`: the external API, or an error naming the missing settings. */
11
+ export declare function resolveExternalBackfillModels(host: BackfillHost, config: ExternalConfig, externalModels?: (flags: Record<string, never>) => SelectedImportModel): Promise<{
12
+ model: string;
13
+ models: HistoryImportModels;
14
+ }>;
15
+ export {};
@@ -0,0 +1,14 @@
1
+ import { externalModelIssues } from "../services/ai/live-model-choice.js";
2
+ /** A backfill set to `external`: the external API, or an error naming the missing settings. */
3
+ export async function resolveExternalBackfillModels(host, config, externalModels) {
4
+ const issues = externalModelIssues(config);
5
+ const label = host === "pi" ? "Pi" : "OpenCode";
6
+ if (issues.length > 0)
7
+ throw new Error(`${label} backfill: ${issues.join("; ")}`);
8
+ const select = externalModels ?? (await import("./model-selection.js")).selectImportModel;
9
+ const external = select({});
10
+ return {
11
+ model: `${external.provider}/${external.modelId}`,
12
+ models: { capture: external.capture, profile: external.profile },
13
+ };
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { ImportPathMap } from "./importer.js";
2
+ /**
3
+ * Validate and normalise `importPathMaps` from the global config. Each entry
4
+ * needs absolute `from` and `to` directories after `~` expansion. Throws on
5
+ * the first invalid entry, like the rest of config validation.
6
+ */
7
+ export declare function parseImportPathMaps(value: unknown): ImportPathMap[];
8
+ /** Saved maps with the run's own maps on top; a run map wins for the same `from`. */
9
+ export declare function mergeImportPathMaps(saved: readonly ImportPathMap[], run: readonly ImportPathMap[]): ImportPathMap[];
10
+ /**
11
+ * The maps one import run uses: the saved `importPathMaps` with the run's
12
+ * maps on top, run targets resolved against `cwd`.
13
+ */
14
+ export declare function runPathMaps(runMaps: readonly ImportPathMap[], cwd: string, saved?: readonly ImportPathMap[]): Promise<ImportPathMap[]>;
@@ -0,0 +1,49 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ function expandHome(path) {
4
+ if (path === "~")
5
+ return homedir();
6
+ if (path.startsWith("~/") || path.startsWith("~\\"))
7
+ return join(homedir(), path.slice(2));
8
+ return path;
9
+ }
10
+ /**
11
+ * Validate and normalise `importPathMaps` from the global config. Each entry
12
+ * needs absolute `from` and `to` directories after `~` expansion. Throws on
13
+ * the first invalid entry, like the rest of config validation.
14
+ */
15
+ export function parseImportPathMaps(value) {
16
+ if (value === undefined)
17
+ return [];
18
+ if (!Array.isArray(value))
19
+ throw new Error("Invalid importPathMaps config: must be a list");
20
+ return value.map((entry, index) => {
21
+ const from = entry?.from;
22
+ const to = entry?.to;
23
+ if (typeof from !== "string" || typeof to !== "string" || !from.trim() || !to.trim()) {
24
+ throw new Error(`Invalid importPathMaps config: entry ${index} needs "from" and "to"`);
25
+ }
26
+ const expanded = { from: expandHome(from.trim()), to: expandHome(to.trim()) };
27
+ if (!isAbsolute(expanded.from) || !isAbsolute(expanded.to)) {
28
+ throw new Error(`Invalid importPathMaps config: entry ${index} needs absolute paths`);
29
+ }
30
+ return { from: resolve(expanded.from), to: resolve(expanded.to) };
31
+ });
32
+ }
33
+ /** Saved maps with the run's own maps on top; a run map wins for the same `from`. */
34
+ export function mergeImportPathMaps(saved, run) {
35
+ const byFrom = new Map();
36
+ for (const map of saved)
37
+ byFrom.set(map.from, map);
38
+ for (const map of run)
39
+ byFrom.set(map.from, map);
40
+ return [...byFrom.values()];
41
+ }
42
+ /**
43
+ * The maps one import run uses: the saved `importPathMaps` with the run's
44
+ * maps on top, run targets resolved against `cwd`.
45
+ */
46
+ export async function runPathMaps(runMaps, cwd, saved) {
47
+ const savedMaps = saved ?? (await import("../config.js")).CONFIG.importPathMaps ?? [];
48
+ return mergeImportPathMaps(savedMaps, runMaps.map((map) => ({ from: map.from, to: resolve(cwd, map.to) })));
49
+ }
@@ -0,0 +1,27 @@
1
+ /** One `(time, done)` reading of a running import. */
2
+ export interface ProgressSample {
3
+ at: number;
4
+ done: number;
5
+ }
6
+ export declare const MAX_PROGRESS_SAMPLES = 20;
7
+ /** Keep the most recent samples; a sample with no new units only moves the clock. */
8
+ export declare function addProgressSample(samples: readonly ProgressSample[], sample: ProgressSample): ProgressSample[];
9
+ export interface ProgressEstimate {
10
+ percent: number;
11
+ /** Null until the recent window has 5 samples spanning a minute and a non-zero rate. */
12
+ minutesLeft: number | null;
13
+ /** Units a minute over the recent window, or null while unknown. */
14
+ ratePerMinute: number | null;
15
+ }
16
+ /** Percentage done and minutes left from the recent sample window, not the whole run. */
17
+ export declare function estimateProgress(total: number, done: number, samples: readonly ProgressSample[]): ProgressEstimate;
18
+ /**
19
+ * Turn the importer's progress callback into finished work units. The
20
+ * importer reports a unit as it starts, and `alreadyHandled` (units found in
21
+ * the ledger, which need no model call) does not yet include that unit, so
22
+ * finished work is `processed - 1 - alreadyHandled`.
23
+ */
24
+ export declare function workProgress(processed: number, total: number, alreadyHandled?: number, expectedTotal?: number): {
25
+ done: number;
26
+ total: number;
27
+ };
@@ -0,0 +1,38 @@
1
+ export const MAX_PROGRESS_SAMPLES = 20;
2
+ const MIN_SAMPLES = 5;
3
+ const MIN_WINDOW_MS = 60_000;
4
+ /** Keep the most recent samples; a sample with no new units only moves the clock. */
5
+ export function addProgressSample(samples, sample) {
6
+ return [...samples, sample].slice(-MAX_PROGRESS_SAMPLES);
7
+ }
8
+ /** Percentage done and minutes left from the recent sample window, not the whole run. */
9
+ export function estimateProgress(total, done, samples) {
10
+ const percent = total > 0 ? Math.min(100, Math.floor((done / total) * 100)) : 0;
11
+ if (samples.length < MIN_SAMPLES)
12
+ return { percent, minutesLeft: null, ratePerMinute: null };
13
+ const first = samples[0];
14
+ const last = samples[samples.length - 1];
15
+ const span = last.at - first.at;
16
+ if (span < MIN_WINDOW_MS || last.done <= first.done) {
17
+ return { percent, minutesLeft: null, ratePerMinute: null };
18
+ }
19
+ const ratePerMinute = (last.done - first.done) / (span / 60_000);
20
+ return {
21
+ percent,
22
+ minutesLeft: Math.max(0, Math.round((total - done) / ratePerMinute)),
23
+ ratePerMinute,
24
+ };
25
+ }
26
+ /**
27
+ * Turn the importer's progress callback into finished work units. The
28
+ * importer reports a unit as it starts, and `alreadyHandled` (units found in
29
+ * the ledger, which need no model call) does not yet include that unit, so
30
+ * finished work is `processed - 1 - alreadyHandled`.
31
+ */
32
+ export function workProgress(processed, total, alreadyHandled = 0, expectedTotal) {
33
+ const workTotal = Math.max(0, expectedTotal ?? total - alreadyHandled);
34
+ return {
35
+ done: Math.min(workTotal, Math.max(0, processed - 1 - alreadyHandled)),
36
+ total: workTotal,
37
+ };
38
+ }
@@ -24,7 +24,7 @@ export async function importReadiness(deps = {}) {
24
24
  const [state, opencode, piReader] = await Promise.all([
25
25
  externalApiState(deps.supportedProviders),
26
26
  (deps.listOpencodeModels ??
27
- (async () => (await import("../services/settings-models.js")).listOpencodeSettingsModels()))(),
27
+ (async () => (await import("./settings-models.js")).listOpencodeSettingsModels()))(),
28
28
  (deps.loadPiSdk ?? (() => import("@earendil-works/pi-coding-agent")))().then(() => ({ available: true }), () => ({
29
29
  available: false,
30
30
  reason: "The Pi SDK (@earendil-works/pi-coding-agent) is not installed for OpenCode",
@@ -0,0 +1,43 @@
1
+ import type { BackfillHost } from "./backfill-model.js";
2
+ import { type ProgressEstimate } from "./import-progress.js";
3
+ export type ImportSurface = "auto" | "web" | "cli" | "slash";
4
+ export type ImportRunState = "running" | "paused" | "stopped" | "done" | "failed";
5
+ export interface ImportRunCounts {
6
+ total: number;
7
+ done: number;
8
+ imported: number;
9
+ skipped: number;
10
+ failed: number;
11
+ }
12
+ export interface ImportRun extends ImportRunCounts, ProgressEstimate {
13
+ host: BackfillHost;
14
+ surface: ImportSurface | null;
15
+ state: ImportRunState | null;
16
+ pid: number | null;
17
+ startedAt: number | null;
18
+ updatedAt: number | null;
19
+ error: string | null;
20
+ /** The user paused this host's backfill; automatic runs wait for Resume. */
21
+ paused: boolean;
22
+ }
23
+ /** Read a host's run without creating the store; a running row whose process is gone reads as stopped. */
24
+ export declare function readImportRun(host: BackfillHost): Promise<ImportRun | null>;
25
+ export declare function isBackfillPaused(host: BackfillHost): Promise<boolean>;
26
+ export declare function setBackfillPaused(host: BackfillHost, paused: boolean): Promise<void>;
27
+ export interface ImportRunRecorder {
28
+ /** Feed from `onProgress`; writes at most once per interval and checks the paused flag. */
29
+ progress(done: number, total: number): void;
30
+ /** True after a progress write saw the paused flag. */
31
+ readonly pauseRequested: boolean;
32
+ finish(state: Exclude<ImportRunState, "running">, counts: Partial<ImportRunCounts>, error?: unknown): Promise<void>;
33
+ }
34
+ /**
35
+ * Start a host's run record. Only numbers, the surface, and the PID are
36
+ * stored; `promptPreview` and all other conversation content never are.
37
+ */
38
+ export declare function startImportRun(host: BackfillHost, surface: ImportSurface, options?: {
39
+ now?: () => number;
40
+ intervalMs?: number;
41
+ sampleIntervalMs?: number;
42
+ onPause?: () => void;
43
+ }): Promise<ImportRunRecorder>;
@@ -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
+ }