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.
- package/{LICENSE → LICENSE.md} +4 -2
- package/README.md +108 -532
- package/dist/adapters/opencode/auto-capture-summary.js +23 -17
- package/dist/adapters/opencode/import-command.d.ts +42 -0
- package/dist/adapters/opencode/import-command.js +154 -0
- package/dist/adapters/pi/extension.js +4 -5
- package/dist/adapters/pi/import-command.d.ts +7 -19
- package/dist/adapters/pi/import-command.js +46 -161
- package/dist/adapters/pi/live-model.d.ts +17 -0
- package/dist/adapters/pi/live-model.js +86 -0
- package/dist/adapters/pi/profile.d.ts +7 -17
- package/dist/adapters/pi/profile.js +28 -66
- package/dist/adapters/pi/provider.d.ts +8 -1
- package/dist/adapters/pi/provider.js +34 -6
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +94 -0
- package/dist/config.d.ts +7 -20
- package/dist/config.js +24 -61
- package/dist/core/extraction.d.ts +2 -2
- package/dist/core/extraction.js +8 -3
- package/dist/core/profile-analysis.d.ts +16 -0
- package/dist/core/profile-analysis.js +47 -0
- package/dist/importer/import-args.d.ts +44 -0
- package/dist/importer/import-args.js +219 -0
- package/dist/importer/importer.d.ts +33 -9
- package/dist/importer/importer.js +153 -89
- package/dist/importer/ledger.d.ts +9 -2
- package/dist/importer/ledger.js +16 -2
- package/dist/importer/model-selection.d.ts +16 -0
- package/dist/importer/model-selection.js +84 -0
- package/dist/importer/opencode-import.d.ts +28 -0
- package/dist/importer/opencode-import.js +87 -0
- package/dist/importer/opencode-project.d.ts +11 -0
- package/dist/importer/opencode-project.js +38 -0
- package/dist/importer/opencode-reader.d.ts +19 -0
- package/dist/importer/opencode-reader.js +214 -0
- package/dist/importer/profile-import.d.ts +29 -0
- package/dist/importer/profile-import.js +111 -0
- package/dist/importer/run-import.d.ts +49 -0
- package/dist/importer/run-import.js +123 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +37 -1
- package/dist/services/ai/internal-capture-sessions.d.ts +2 -0
- package/dist/services/ai/internal-capture-sessions.js +7 -0
- package/dist/services/ai/live-model-choice.d.ts +52 -0
- package/dist/services/ai/live-model-choice.js +83 -0
- package/dist/services/ai/profile-llm-client.js +7 -5
- package/dist/services/user-memory-learning.js +9 -8
- package/dist/services/user-profile/ai-cleanup.js +8 -5
- package/dist/services/user-profile/user-profile-manager.js +11 -10
- package/dist/v2/adapter.js +27 -0
- 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
|
-
|
|
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
|
}
|