om-memory-system 3.0.0 → 3.1.0-next.4

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 (52) hide show
  1. package/{LICENSE → LICENSE.md} +4 -2
  2. package/README.md +108 -532
  3. package/dist/adapters/opencode/auto-capture-summary.js +23 -17
  4. package/dist/adapters/opencode/import-command.d.ts +42 -0
  5. package/dist/adapters/opencode/import-command.js +154 -0
  6. package/dist/adapters/pi/extension.js +4 -5
  7. package/dist/adapters/pi/import-command.d.ts +7 -19
  8. package/dist/adapters/pi/import-command.js +46 -161
  9. package/dist/adapters/pi/live-model.d.ts +17 -0
  10. package/dist/adapters/pi/live-model.js +86 -0
  11. package/dist/adapters/pi/profile.d.ts +7 -17
  12. package/dist/adapters/pi/profile.js +28 -66
  13. package/dist/adapters/pi/provider.d.ts +8 -1
  14. package/dist/adapters/pi/provider.js +34 -6
  15. package/dist/cli/index.d.ts +7 -0
  16. package/dist/cli/index.js +94 -0
  17. package/dist/config.d.ts +7 -20
  18. package/dist/config.js +24 -61
  19. package/dist/core/extraction.d.ts +2 -2
  20. package/dist/core/extraction.js +8 -3
  21. package/dist/core/profile-analysis.d.ts +16 -0
  22. package/dist/core/profile-analysis.js +47 -0
  23. package/dist/importer/import-args.d.ts +44 -0
  24. package/dist/importer/import-args.js +219 -0
  25. package/dist/importer/importer.d.ts +33 -9
  26. package/dist/importer/importer.js +153 -89
  27. package/dist/importer/ledger.d.ts +9 -2
  28. package/dist/importer/ledger.js +16 -2
  29. package/dist/importer/model-selection.d.ts +16 -0
  30. package/dist/importer/model-selection.js +84 -0
  31. package/dist/importer/opencode-import.d.ts +28 -0
  32. package/dist/importer/opencode-import.js +87 -0
  33. package/dist/importer/opencode-project.d.ts +11 -0
  34. package/dist/importer/opencode-project.js +38 -0
  35. package/dist/importer/opencode-reader.d.ts +19 -0
  36. package/dist/importer/opencode-reader.js +214 -0
  37. package/dist/importer/profile-import.d.ts +29 -0
  38. package/dist/importer/profile-import.js +111 -0
  39. package/dist/importer/run-import.d.ts +49 -0
  40. package/dist/importer/run-import.js +123 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +37 -1
  43. package/dist/services/ai/internal-capture-sessions.d.ts +2 -0
  44. package/dist/services/ai/internal-capture-sessions.js +7 -0
  45. package/dist/services/ai/live-model-choice.d.ts +52 -0
  46. package/dist/services/ai/live-model-choice.js +83 -0
  47. package/dist/services/ai/profile-llm-client.js +7 -5
  48. package/dist/services/user-memory-learning.js +9 -8
  49. package/dist/services/user-profile/ai-cleanup.js +8 -5
  50. package/dist/services/user-profile/user-profile-manager.js +11 -10
  51. package/dist/v2/adapter.js +27 -0
  52. package/package.json +5 -2
@@ -0,0 +1,19 @@
1
+ import type { ImportSourceSession } from "./importer.js";
2
+ export interface OpencodeSourceSession extends ImportSourceSession {
3
+ recordedDirectory: string;
4
+ projectWorktree: string | null;
5
+ timeCreated: number;
6
+ }
7
+ export interface OpencodeReader {
8
+ childSessions: number;
9
+ topLevelSessions: number;
10
+ sessions: AsyncIterable<OpencodeSourceSession>;
11
+ }
12
+ export interface OpencodeReaderFilters {
13
+ session?: string;
14
+ maxSessions?: number;
15
+ since?: number;
16
+ until?: number;
17
+ }
18
+ /** Read V1 OpenCode history without changing its database or WAL sidecars. */
19
+ export declare function readOpencodeHistory(dbPath: string, filters?: OpencodeReaderFilters): OpencodeReader;
@@ -0,0 +1,214 @@
1
+ import { constants, copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { INTERNAL_CAPTURE_SESSION_TITLES } from "../services/ai/internal-capture-sessions.js";
7
+ function validateSchema(db) {
8
+ const required = {
9
+ session: ["id", "directory", "project_id", "parent_id", "time_created"],
10
+ project: ["id", "worktree"],
11
+ message: ["id", "session_id", "time_created", "data"],
12
+ part: ["id", "message_id", "session_id", "time_created", "data"],
13
+ };
14
+ for (const [table, columns] of Object.entries(required)) {
15
+ const found = db
16
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
17
+ .get(table);
18
+ if (!found)
19
+ throw new Error(`Unsupported OpenCode V1 database: missing ${table} table`);
20
+ const names = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
21
+ for (const column of columns) {
22
+ if (!names.has(column))
23
+ throw new Error(`Unsupported OpenCode V1 database: missing ${table}.${column}`);
24
+ }
25
+ }
26
+ }
27
+ function parseData(json) {
28
+ try {
29
+ const parsed = JSON.parse(json);
30
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
31
+ ? parsed
32
+ : {};
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ }
38
+ function boundInput(value) {
39
+ const text = typeof value === "string" ? value : JSON.stringify(value ?? "");
40
+ return text.length > 100 ? `${text.slice(0, 100)}...` : text;
41
+ }
42
+ function buildWindows(db, sessionId, filters) {
43
+ const messages = db
44
+ .prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
45
+ .all(sessionId);
46
+ const parts = db
47
+ .prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
48
+ .all(sessionId);
49
+ const byMessage = new Map();
50
+ for (const part of parts) {
51
+ const entries = byMessage.get(part.message_id) ?? [];
52
+ entries.push(parseData(part.data));
53
+ byMessage.set(part.message_id, entries);
54
+ }
55
+ const units = [];
56
+ let current = null;
57
+ for (const message of messages) {
58
+ const role = parseData(message.data).role;
59
+ const content = byMessage.get(message.id) ?? [];
60
+ if (role === "user") {
61
+ if (current?.sourceEntryIds?.length)
62
+ units.push(current);
63
+ const prompt = content
64
+ .filter((part) => part.type === "text" &&
65
+ !part.synthetic &&
66
+ !part.ignored &&
67
+ typeof part.text === "string")
68
+ .map((part) => part.text)
69
+ .join("\n")
70
+ .trim();
71
+ const timestamp = Number(message.time_created);
72
+ current =
73
+ prompt &&
74
+ (filters.since === undefined || timestamp >= filters.since) &&
75
+ (filters.until === undefined || timestamp <= filters.until)
76
+ ? {
77
+ userEntryId: message.id,
78
+ userPrompt: prompt,
79
+ userTimestamp: timestamp,
80
+ sourceTimestamp: timestamp,
81
+ textResponses: [],
82
+ toolCalls: [],
83
+ sourceEntryIds: [],
84
+ }
85
+ : null;
86
+ continue;
87
+ }
88
+ if (role !== "assistant" || !current)
89
+ continue;
90
+ current.sourceEntryIds.push(message.id);
91
+ for (const part of content) {
92
+ if (part.type === "text" && typeof part.text === "string" && !part.synthetic) {
93
+ current.textResponses.push(part.text);
94
+ }
95
+ else if (part.type === "tool" || part.type === "agent" || part.type === "subtask") {
96
+ const state = part.state && typeof part.state === "object"
97
+ ? part.state
98
+ : {};
99
+ current.toolCalls.push({
100
+ name: String(part.tool ?? part.name ?? part.type),
101
+ input: boundInput(state.input ?? part.input ?? ""),
102
+ });
103
+ }
104
+ }
105
+ }
106
+ if (current?.sourceEntryIds?.length)
107
+ units.push(current);
108
+ return units;
109
+ }
110
+ function fileStamp(path) {
111
+ const info = statSync(path, { bigint: true });
112
+ return `${info.size}:${info.mtimeNs}`;
113
+ }
114
+ /**
115
+ * Copy the database and its WAL into a private folder. `immutable=1` ignores
116
+ * the WAL, and a normal open writes to OpenCode's `-shm`, so a copy is the only
117
+ * way to see uncheckpointed turns while leaving every source file untouched.
118
+ * A copy that raced a write or checkpoint is discarded and retried.
119
+ */
120
+ function snapshotWalDatabase(dbPath) {
121
+ const dir = mkdtempSync(join(tmpdir(), "omms-opencode-"));
122
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
123
+ const copy = join(dir, "opencode.db");
124
+ try {
125
+ for (let attempt = 0; attempt < 5; attempt++) {
126
+ const before = [fileStamp(dbPath), fileStamp(`${dbPath}-wal`)];
127
+ // Clone where the file system supports it (APFS, Btrfs): large databases copy instantly.
128
+ copyFileSync(dbPath, copy, constants.COPYFILE_FICLONE);
129
+ copyFileSync(`${dbPath}-wal`, `${copy}-wal`, constants.COPYFILE_FICLONE);
130
+ const after = [fileStamp(dbPath), fileStamp(`${dbPath}-wal`)];
131
+ if (before[0] === after[0] && before[1] === after[1])
132
+ return { path: copy, cleanup };
133
+ }
134
+ }
135
+ catch (error) {
136
+ cleanup();
137
+ throw error;
138
+ }
139
+ cleanup();
140
+ throw new Error("OpenCode database kept changing while it was copied; retry the import");
141
+ }
142
+ function openSnapshot(dbPath) {
143
+ if (!existsSync(`${dbPath}-wal`)) {
144
+ // No WAL: every committed page is in the main file, so read it in place.
145
+ const uri = `${pathToFileURL(dbPath).href}?immutable=1`;
146
+ return { db: new DatabaseSync(uri, { readOnly: true }), cleanup: () => { } };
147
+ }
148
+ const snapshot = snapshotWalDatabase(dbPath);
149
+ try {
150
+ return { db: new DatabaseSync(snapshot.path), cleanup: snapshot.cleanup };
151
+ }
152
+ catch (error) {
153
+ snapshot.cleanup();
154
+ throw error;
155
+ }
156
+ }
157
+ /** Read V1 OpenCode history without changing its database or WAL sidecars. */
158
+ export function readOpencodeHistory(dbPath, filters = {}) {
159
+ if (!existsSync(dbPath))
160
+ throw new Error(`OpenCode database not found: ${dbPath}`);
161
+ const { db, cleanup } = openSnapshot(dbPath);
162
+ const close = () => {
163
+ db.close();
164
+ cleanup();
165
+ };
166
+ try {
167
+ validateSchema(db);
168
+ // omms's own capture/profile calls run in OpenCode sessions; never import them as history.
169
+ const hasTitle = db.prepare("PRAGMA table_info(session)").all().some((column) => column.name === "title");
170
+ const notInternal = hasTitle
171
+ ? `AND (s.title IS NULL OR s.title NOT IN (${INTERNAL_CAPTURE_SESSION_TITLES.map(() => "?").join(", ")}))`
172
+ : "";
173
+ const internalArgs = hasTitle ? [...INTERNAL_CAPTURE_SESSION_TITLES] : [];
174
+ const count = (where) => Number(db
175
+ .prepare(`SELECT COUNT(*) AS count FROM session s WHERE ${where} ${notInternal}`)
176
+ .get(...internalArgs).count);
177
+ const childSessions = count("s.parent_id IS NOT NULL");
178
+ const topLevelSessions = count("s.parent_id IS NULL");
179
+ const sessions = {
180
+ async *[Symbol.asyncIterator]() {
181
+ try {
182
+ const query = `SELECT s.id, s.directory, p.worktree AS project_worktree, s.time_created
183
+ FROM session s LEFT JOIN project p ON p.id = s.project_id
184
+ WHERE s.parent_id IS NULL ${notInternal} ${filters.session ? "AND s.id = ?" : ""}
185
+ ORDER BY s.time_created, s.id ${filters.maxSessions ? "LIMIT ?" : ""}`;
186
+ const args = [...internalArgs];
187
+ if (filters.session)
188
+ args.push(filters.session);
189
+ if (filters.maxSessions)
190
+ args.push(filters.maxSessions);
191
+ for (const row of db.prepare(query).all(...args)) {
192
+ yield {
193
+ sessionId: row.id,
194
+ recordedDirectory: row.directory,
195
+ directory: row.directory,
196
+ sourceFile: dbPath,
197
+ projectWorktree: row.project_worktree,
198
+ timeCreated: row.time_created,
199
+ units: buildWindows(db, row.id, filters),
200
+ };
201
+ }
202
+ }
203
+ finally {
204
+ close();
205
+ }
206
+ },
207
+ };
208
+ return { childSessions, topLevelSessions, sessions };
209
+ }
210
+ catch (error) {
211
+ close();
212
+ throw error;
213
+ }
214
+ }
@@ -0,0 +1,29 @@
1
+ import { type ModelPort } from "../core/profile-analysis.js";
2
+ import { userProfileManager } from "../services/user-profile/user-profile-manager.js";
3
+ import { userPromptManager } from "../services/user-prompt/user-prompt-manager.js";
4
+ import type { MemoryHost } from "../types/index.js";
5
+ import { type ImportSourceSession } from "./importer.js";
6
+ import { ImportLedger } from "./ledger.js";
7
+ export interface ProfileImportReport {
8
+ promptsRecorded: number;
9
+ promptsWouldRecord: number;
10
+ promptsAlreadyHandled: number;
11
+ batchesBuilt: number;
12
+ remaining: number;
13
+ error?: string;
14
+ }
15
+ type PromptStore = Pick<typeof userPromptManager, "savePrompt" | "markAsCaptured" | "countUnanalyzedForUserLearning" | "getPromptsForUserLearning" | "markMultipleAsUserLearningCaptured">;
16
+ type ProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "createProfile" | "updateProfile">;
17
+ type Ledger = Pick<ImportLedger, "get" | "begin" | "complete"> & Partial<Pick<ImportLedger, "peek">>;
18
+ export interface ProfileImportOptions {
19
+ host: MemoryHost;
20
+ dryRun?: boolean;
21
+ batchSize?: number;
22
+ model?: ModelPort;
23
+ ledger?: Ledger;
24
+ promptStore?: PromptStore;
25
+ profileStore?: ProfileStore;
26
+ }
27
+ /** Record historical prompts and build or update the user profile in batches. */
28
+ export declare function importProfileFromHistory(source: AsyncIterable<ImportSourceSession> | Iterable<ImportSourceSession>, options: ProfileImportOptions): Promise<ProfileImportReport>;
29
+ export {};
@@ -0,0 +1,111 @@
1
+ import { isInternalPrompt } from "../adapters/opencode/user-prompt.js";
2
+ import { analyzeProfile } from "../core/profile-analysis.js";
3
+ import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
4
+ import { getTags } from "../services/tags.js";
5
+ import { userProfileManager } from "../services/user-profile/user-profile-manager.js";
6
+ import { userPromptManager } from "../services/user-prompt/user-prompt-manager.js";
7
+ import { buildImportKey } from "./importer.js";
8
+ import { existsSync } from "node:fs";
9
+ import { ImportLedger, importLedgerDbPath } from "./ledger.js";
10
+ /** Record historical prompts and build or update the user profile in batches. */
11
+ export async function importProfileFromHistory(source, options) {
12
+ if (!options.dryRun && !options.model)
13
+ throw new Error("Profile import needs a model");
14
+ const batchSize = options.batchSize ?? 50;
15
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1) {
16
+ throw new Error("Profile batch size must be a positive integer");
17
+ }
18
+ const report = {
19
+ promptsRecorded: 0,
20
+ promptsWouldRecord: 0,
21
+ promptsAlreadyHandled: 0,
22
+ batchesBuilt: 0,
23
+ remaining: 0,
24
+ };
25
+ const ledger = options.ledger ?? new ImportLedger();
26
+ // A dry run reads the ledger only when it exists, so a preview never creates it.
27
+ const ledgerReadable = !options.dryRun || Boolean(options.ledger) || existsSync(importLedgerDbPath());
28
+ const prompts = options.promptStore ?? userPromptManager;
29
+ const profiles = options.profileStore ?? userProfileManager;
30
+ let directory;
31
+ for await (const session of source) {
32
+ for (const unit of session.units) {
33
+ const prompt = stripPrivateContent(unit.userPrompt).trim();
34
+ if (!prompt ||
35
+ isFullyPrivate(unit.userPrompt) ||
36
+ isInternalPrompt(session.sessionId, prompt)) {
37
+ continue;
38
+ }
39
+ const key = buildImportKey(session.sessionId, unit, options.host);
40
+ if (!key)
41
+ continue;
42
+ directory ??= session.directory;
43
+ const profileKey = `${key}#profile`;
44
+ const existing = !ledgerReadable
45
+ ? null
46
+ : options.dryRun && ledger.peek
47
+ ? await ledger.peek(profileKey)
48
+ : await ledger.get(profileKey);
49
+ if (existing?.status === "imported") {
50
+ report.promptsAlreadyHandled++;
51
+ continue;
52
+ }
53
+ if (options.dryRun) {
54
+ report.promptsWouldRecord++;
55
+ continue;
56
+ }
57
+ await ledger.begin({
58
+ key: profileKey,
59
+ sessionId: session.sessionId,
60
+ sourceFile: session.sourceFile,
61
+ projectHash: "profile",
62
+ });
63
+ // savePrompt also deduplicates a retry after a crash before ledger completion.
64
+ const promptId = await prompts.savePrompt(session.sessionId, unit.userEntryId, session.directory, prompt);
65
+ await prompts.markAsCaptured(promptId);
66
+ await ledger.complete(profileKey, promptId);
67
+ report.promptsRecorded++;
68
+ }
69
+ }
70
+ if (options.dryRun || !directory)
71
+ return report;
72
+ const user = getTags(directory).user;
73
+ if (!user.userEmail) {
74
+ report.remaining = await prompts.countUnanalyzedForUserLearning();
75
+ report.error = "Profile import needs a user email";
76
+ return report;
77
+ }
78
+ const model = options.model;
79
+ while ((report.remaining = await prompts.countUnanalyzedForUserLearning()) > 0) {
80
+ const batch = await prompts.getPromptsForUserLearning(batchSize);
81
+ if (batch.length === 0)
82
+ break;
83
+ let succeeded = false;
84
+ for (let attempt = 0; attempt < 2 && !succeeded; attempt++) {
85
+ try {
86
+ const existing = await profiles.getActiveProfile(user.userEmail);
87
+ const context = batch.map((item, index) => `${index + 1}. ${item.content}`).join("\n");
88
+ const analysis = await analyzeProfile(model, context, existing ? { id: existing.id, profileData: existing.profileData } : null);
89
+ if (existing) {
90
+ if (!analysis.merged ||
91
+ !(await profiles.updateProfile(existing.id, analysis.merged, batch.length, `History import of ${batch.length} prompts`)))
92
+ throw new Error("Profile update conflict");
93
+ }
94
+ else {
95
+ await profiles.createProfile(user.userEmail, user.displayName || user.userEmail, user.userName || user.userEmail, user.userEmail, analysis.raw, batch.length);
96
+ }
97
+ await prompts.markMultipleAsUserLearningCaptured(batch.map((item) => item.id));
98
+ report.batchesBuilt++;
99
+ succeeded = true;
100
+ }
101
+ catch (error) {
102
+ if (attempt === 1) {
103
+ report.error = error instanceof Error ? error.message : String(error);
104
+ report.remaining = await prompts.countUnanalyzedForUserLearning();
105
+ return report;
106
+ }
107
+ }
108
+ }
109
+ }
110
+ return report;
111
+ }
@@ -0,0 +1,49 @@
1
+ import type { CaptureSummaryProvider } from "../core/host.js";
2
+ import type { ModelPort } from "../core/profile-analysis.js";
3
+ import type { HistoryImportArgs, ImportHost } from "./import-args.js";
4
+ import type { ImportReport } from "./importer.js";
5
+ import type { UnresolvedProject } from "./opencode-project.js";
6
+ /** The two model roles an import uses; absent in a dry run or when the step is skipped. */
7
+ export interface HistoryImportModels {
8
+ capture?: CaptureSummaryProvider;
9
+ profile?: ModelPort;
10
+ }
11
+ export interface HistoryImportRun {
12
+ /** Directory that relative paths and the default project resolve against. */
13
+ cwd: string;
14
+ models: HistoryImportModels;
15
+ onProgress?: (processed: number, total: number, promptPreview: string) => void;
16
+ }
17
+ export type HistoryImportReport = ImportReport & {
18
+ childSessionsFolded?: number;
19
+ unresolvedProjects?: UnresolvedProject[];
20
+ };
21
+ /** Run one host's history import with already-parsed, validated arguments. */
22
+ export declare function runHistoryImport(host: ImportHost, args: HistoryImportArgs, run: HistoryImportRun): Promise<HistoryImportReport>;
23
+ /** The same plain-text report for every host and surface. */
24
+ export declare function formatHistoryImportReport(host: ImportHost, report: HistoryImportReport, model?: string): string;
25
+ /** Counts and identifiers for the log. Unit previews hold prompt text, so they stay out. */
26
+ export declare function summarizeHistoryImportReport(report: HistoryImportReport): {
27
+ dryRun: boolean;
28
+ sessionsDiscovered: number;
29
+ sessionsLoaded: number;
30
+ sessionsFilteredOut: number;
31
+ unitsTotal: number;
32
+ unitsImported: number;
33
+ unitsWouldImport: number;
34
+ unitsSkipped: number;
35
+ unitsFailed: number;
36
+ unitsAlreadyHandled: number;
37
+ projects: number;
38
+ unresolved: number;
39
+ loadErrors: number;
40
+ profile: {
41
+ promptsRecorded: number;
42
+ promptsWouldRecord: number;
43
+ batchesBuilt: number;
44
+ remaining: number;
45
+ failed: boolean;
46
+ } | undefined;
47
+ };
48
+ /** A failed unit or profile step makes the whole run unsuccessful. */
49
+ export declare function historyImportFailed(report: HistoryImportReport): boolean;
@@ -0,0 +1,123 @@
1
+ import { resolve } from "node:path";
2
+ const dryRunCapture = {
3
+ summarize: async () => {
4
+ throw new Error("A dry-run cannot call the capture model");
5
+ },
6
+ };
7
+ /** Run one host's history import with already-parsed, validated arguments. */
8
+ export async function runHistoryImport(host, args, run) {
9
+ const at = (path) => resolve(run.cwd, path);
10
+ const project = args.scope === "current-project" ? at(args.project ?? ".") : undefined;
11
+ const pathMaps = args.pathMaps.map((map) => ({ from: map.from, to: at(map.to) }));
12
+ const capture = run.models.capture ?? dryRunCapture;
13
+ if (host === "opencode") {
14
+ const { importOpencodeHistory } = await import("./opencode-import.js");
15
+ return importOpencodeHistory({
16
+ ...(args.source ? { dbPath: at(args.source) } : {}),
17
+ dryRun: args.dryRun,
18
+ ...(args.since !== undefined ? { since: args.since } : {}),
19
+ ...(args.until !== undefined ? { until: args.until } : {}),
20
+ ...(args.session ? { session: args.session } : {}),
21
+ ...(project ? { project } : {}),
22
+ ...(args.maxSessions ? { maxSessions: args.maxSessions } : {}),
23
+ ...(args.profileBatch ? { profileBatch: args.profileBatch } : {}),
24
+ pathMaps,
25
+ force: args.force,
26
+ skipMemories: args.skipMemories,
27
+ skipProfile: args.skipProfile,
28
+ ...(run.models.capture ? { provider: capture } : {}),
29
+ ...(run.models.profile ? { profileModel: run.models.profile } : {}),
30
+ });
31
+ }
32
+ const { importPiHistory } = await import("./importer.js");
33
+ const { loadPiSessionForImport } = await import("./session-loader.js");
34
+ return importPiHistory({
35
+ loadSession: loadPiSessionForImport,
36
+ provider: capture,
37
+ ...(run.onProgress ? { onProgress: run.onProgress } : {}),
38
+ ...(!args.skipProfile
39
+ ? {
40
+ profile: {
41
+ ...(run.models.profile ? { model: run.models.profile } : {}),
42
+ ...(args.profileBatch ? { batchSize: args.profileBatch } : {}),
43
+ },
44
+ }
45
+ : {}),
46
+ }, {
47
+ scope: args.scope,
48
+ currentDirectory: project ?? run.cwd,
49
+ ...(args.session ? { session: args.session } : {}),
50
+ ...(args.since !== undefined ? { since: args.since } : {}),
51
+ ...(args.until !== undefined ? { until: args.until } : {}),
52
+ ...(args.maxSessions ? { maxSessions: args.maxSessions } : {}),
53
+ ...(args.source ? { root: at(args.source) } : {}),
54
+ force: args.force,
55
+ dryRun: args.dryRun,
56
+ skipMemories: args.skipMemories,
57
+ pathMaps,
58
+ });
59
+ }
60
+ /** The same plain-text report for every host and surface. */
61
+ export function formatHistoryImportReport(host, report, model) {
62
+ const lines = [
63
+ `${host === "pi" ? "Pi" : "OpenCode"} history import${report.dryRun ? " (dry-run)" : ""}`,
64
+ ];
65
+ if (model)
66
+ lines.push(` model: ${model}`);
67
+ lines.push(` sessions: ${report.sessionsLoaded}/${report.sessionsDiscovered} loaded, ${report.sessionsFilteredOut} filtered out` +
68
+ (report.childSessionsFolded !== undefined
69
+ ? `, ${report.childSessionsFolded} child sessions folded`
70
+ : ""));
71
+ lines.push(` memory units: ${report.unitsWouldImport} pending, ${report.unitsAlreadyHandled} already done, ` +
72
+ `${report.unitsImported} imported, ${report.unitsSkipped} skipped, ${report.unitsFailed} failed`);
73
+ for (const project of report.projects) {
74
+ lines.push(` project ${project.directory}: ${project.sessions} sessions, ${project.units} units`);
75
+ }
76
+ for (const unresolved of report.unresolvedProjects ?? []) {
77
+ lines.push(` unresolved ${unresolved.directory}: ${unresolved.sessions} sessions, ${unresolved.units} units (use --map)`);
78
+ }
79
+ for (const unresolvable of report.unresolvableSessions) {
80
+ lines.push(` unresolved ${unresolvable.cwd ?? "(no cwd)"}: ${unresolvable.file} (use --map)`);
81
+ }
82
+ if (report.loadErrors.length > 0)
83
+ lines.push(` load errors: ${report.loadErrors.length}`);
84
+ if (report.profile) {
85
+ lines.push(` profile prompts: ${report.profile.promptsWouldRecord} pending, ${report.profile.promptsRecorded} recorded, ` +
86
+ `${report.profile.promptsAlreadyHandled} already done; ${report.profile.batchesBuilt} batches, ` +
87
+ `${report.profile.remaining} remaining`);
88
+ if (report.profile.error)
89
+ lines.push(` profile error: ${report.profile.error}`);
90
+ }
91
+ return lines.join("\n");
92
+ }
93
+ /** Counts and identifiers for the log. Unit previews hold prompt text, so they stay out. */
94
+ export function summarizeHistoryImportReport(report) {
95
+ return {
96
+ dryRun: report.dryRun,
97
+ sessionsDiscovered: report.sessionsDiscovered,
98
+ sessionsLoaded: report.sessionsLoaded,
99
+ sessionsFilteredOut: report.sessionsFilteredOut,
100
+ unitsTotal: report.unitsTotal,
101
+ unitsImported: report.unitsImported,
102
+ unitsWouldImport: report.unitsWouldImport,
103
+ unitsSkipped: report.unitsSkipped,
104
+ unitsFailed: report.unitsFailed,
105
+ unitsAlreadyHandled: report.unitsAlreadyHandled,
106
+ projects: report.projects.length,
107
+ unresolved: (report.unresolvedProjects?.length ?? 0) + report.unresolvableSessions.length,
108
+ loadErrors: report.loadErrors.length,
109
+ profile: report.profile
110
+ ? {
111
+ promptsRecorded: report.profile.promptsRecorded,
112
+ promptsWouldRecord: report.profile.promptsWouldRecord,
113
+ batchesBuilt: report.profile.batchesBuilt,
114
+ remaining: report.profile.remaining,
115
+ failed: Boolean(report.profile.error),
116
+ }
117
+ : undefined,
118
+ };
119
+ }
120
+ /** A failed unit or profile step makes the whole run unsuccessful. */
121
+ export function historyImportFailed(report) {
122
+ return report.unitsFailed > 0 || Boolean(report.profile?.error);
123
+ }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,10 @@ export { isStructuredSummaryPromptMessage };
13
13
  * 3. Latest non-compaction / non-summary assistant mode (v1) or agent (v2)
14
14
  */
15
15
  export declare function resolveSessionAgent(client: unknown, sessionID: string): Promise<string | undefined>;
16
+ /** Register `/memory-import-opencode-history`; its work happens in command.execute.before. */
17
+ export declare function applyHistoryImportCommandConfig(cfg: {
18
+ command?: Record<string, unknown>;
19
+ }): void;
16
20
  /** Least-privilege agent used only by internal structured-output sessions (issue #189). */
17
21
  export declare function applyStructuredOutputAgentConfig(cfg: {
18
22
  agent?: Record<string, unknown>;
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { performAutoCapture } from "./services/auto-capture.js";
6
6
  import { createOpenCodeAutoCaptureHost } from "./adapters/opencode/auto-capture-host.js";
7
7
  import { isInternalPrompt, isStructuredSummaryPromptMessage, recordUserPrompt, } from "./adapters/opencode/user-prompt.js";
8
8
  import { executeMemoryOperation } from "./core/memory-operations.js";
9
+ import { OPENCODE_IMPORT_COMMAND, OPENCODE_IMPORT_DESCRIPTION, latestUserMessageModel, runOpencodeImportCommand, } from "./adapters/opencode/import-command.js";
9
10
  import { formatMemoriesForCompaction } from "./core/retrieval.js";
10
11
  import { performUserProfileLearning } from "./services/user-memory-learning.js";
11
12
  import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js";
@@ -14,6 +15,7 @@ import { ensureTursoReady } from "./services/turso/ready.js";
14
15
  import { tursoConnectionManager } from "./services/turso/connection-manager.js";
15
16
  import { WebAuth } from "./services/web-auth.js";
16
17
  import { isConfigured, CONFIG, initConfigWithLegacyMigration } from "./config.js";
18
+ import { resolveOpencodeHostModel } from "./services/ai/live-model-choice.js";
17
19
  import { log } from "./services/logger.js";
18
20
  import { getLanguageName } from "./services/language-detector.js";
19
21
  import { getHostClientConfig } from "./services/ai/opencode-host-config.js";
@@ -121,6 +123,16 @@ async function isInternalCaptureSession(client, sessionID) {
121
123
  }
122
124
  return false;
123
125
  }
126
+ /** Register `/memory-import-opencode-history`; its work happens in command.execute.before. */
127
+ export function applyHistoryImportCommandConfig(cfg) {
128
+ cfg.command = {
129
+ ...cfg.command,
130
+ [OPENCODE_IMPORT_COMMAND]: {
131
+ template: "Import OpenCode history into omms: $ARGUMENTS",
132
+ description: OPENCODE_IMPORT_DESCRIPTION,
133
+ },
134
+ };
135
+ }
124
136
  /** Least-privilege agent used only by internal structured-output sessions (issue #189). */
125
137
  export function applyStructuredOutputAgentConfig(cfg) {
126
138
  cfg.agent = {
@@ -375,6 +387,29 @@ export const OmmsPlugin = async (ctx) => {
375
387
  dispose: disposePlugin,
376
388
  config: async (cfg) => {
377
389
  applyStructuredOutputAgentConfig(cfg);
390
+ applyHistoryImportCommandConfig(cfg);
391
+ },
392
+ // V1 runs slash commands as a model turn; do the import here and hand the
393
+ // turn only the finished report to relay.
394
+ "command.execute.before": async (input, output) => {
395
+ if (input.command !== OPENCODE_IMPORT_COMMAND)
396
+ return;
397
+ const report = await runOpencodeImportCommand({
398
+ argsText: input.arguments ?? "",
399
+ directory,
400
+ sessionModel: () => latestUserMessageModel(ctx.client, input.sessionID),
401
+ notify: (message) => {
402
+ void ctx.client?.tui
403
+ ?.showToast({
404
+ body: { title: "omms import", message, variant: "info", duration: 3000 },
405
+ })
406
+ .catch(() => { });
407
+ },
408
+ });
409
+ output.parts.splice(0, output.parts.length, {
410
+ type: "text",
411
+ text: `Reply with this omms history import report exactly as written and nothing else:\n\n\`\`\`\n${report}\n\`\`\``,
412
+ });
378
413
  },
379
414
  "chat.message": async (input, output) => {
380
415
  if (!isConfigured() || !CONFIG.chatMessage.enabled)
@@ -454,7 +489,8 @@ export const OmmsPlugin = async (ctx) => {
454
489
  }
455
490
  },
456
491
  "chat.params": async (input) => {
457
- if (!isConfigured() || CONFIG.opencodeModel !== "inherit")
492
+ // Record the session model whenever live calls follow it ("inherit" or nothing configured).
493
+ if (!isConfigured() || resolveOpencodeHostModel(CONFIG)?.modelID !== "inherit")
458
494
  return;
459
495
  try {
460
496
  await userPromptManager.setPromptModel(input.message.id, input.model.providerID, input.model.id);
@@ -1,5 +1,7 @@
1
1
  /** Title used for transient structured-output sessions (capture / profile learning). */
2
2
  export declare const INTERNAL_CAPTURE_SESSION_TITLE = "omms capture";
3
+ /** Every title omms has used for its own sessions (capture, profile learning, profile cleanup). */
4
+ export declare const INTERNAL_CAPTURE_SESSION_TITLES: readonly ["omms capture", "opencode-mem capture", "omms profile cleanup", "opencode-mem profile cleanup"];
3
5
  export declare function isInternalCaptureSessionTitle(title: string | undefined | null): boolean;
4
6
  export declare function trackInternalCaptureSession(sessionID: string): void;
5
7
  export declare function untrackInternalCaptureSession(sessionID: string): void;
@@ -6,6 +6,13 @@ const LEGACY_INTERNAL_CAPTURE_SESSION_TITLE = "opencode-mem capture";
6
6
  const UNTRACK_GRACE_MS = 60_000;
7
7
  const trackedSessionIDs = new Set();
8
8
  const untrackTimers = new Map();
9
+ /** Every title omms has used for its own sessions (capture, profile learning, profile cleanup). */
10
+ export const INTERNAL_CAPTURE_SESSION_TITLES = [
11
+ INTERNAL_CAPTURE_SESSION_TITLE,
12
+ LEGACY_INTERNAL_CAPTURE_SESSION_TITLE,
13
+ "omms profile cleanup",
14
+ "opencode-mem profile cleanup",
15
+ ];
9
16
  export function isInternalCaptureSessionTitle(title) {
10
17
  return (title === INTERNAL_CAPTURE_SESSION_TITLE || title === LEGACY_INTERNAL_CAPTURE_SESSION_TITLE);
11
18
  }