om-memory-system 3.4.0-next.23 → 3.4.0-next.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/dist/adapters/opencode/backfill-models.js +5 -1
- package/dist/adapters/opencode/backfill-startup.d.ts +6 -0
- package/dist/adapters/opencode/backfill-startup.js +5 -0
- package/dist/adapters/opencode/import-command.js +1 -0
- package/dist/adapters/opencode/user-prompt.d.ts +2 -1
- package/dist/adapters/opencode/user-prompt.js +2 -9
- package/dist/adapters/pi/backfill-models.d.ts +4 -0
- package/dist/adapters/pi/backfill-models.js +3 -0
- package/dist/adapters/pi/capture.d.ts +1 -1
- package/dist/adapters/pi/capture.js +1 -1
- package/dist/adapters/pi/extension.js +7 -0
- package/dist/adapters/pi/import-command.js +1 -0
- package/dist/cli/index.js +13 -1
- package/dist/config.d.ts +11 -4
- package/dist/config.js +29 -6
- package/dist/core/internal-prompt.d.ts +6 -0
- package/dist/core/internal-prompt.js +11 -0
- package/dist/importer/auto-backfill.d.ts +5 -0
- package/dist/importer/auto-backfill.js +29 -10
- package/dist/importer/backfill-controls.d.ts +37 -0
- package/dist/importer/backfill-controls.js +119 -0
- package/dist/importer/backfill-lock.d.ts +1 -0
- package/dist/importer/backfill-lock.js +2 -2
- package/dist/importer/backfill-model.d.ts +1 -1
- package/dist/importer/backfill-model.js +2 -0
- package/dist/importer/external-api-test.d.ts +18 -0
- package/dist/importer/external-api-test.js +50 -0
- package/dist/importer/external-backfill-models.d.ts +15 -0
- package/dist/importer/external-backfill-models.js +14 -0
- package/dist/importer/import-path-maps.d.ts +14 -0
- package/dist/importer/import-path-maps.js +49 -0
- package/dist/importer/import-progress.d.ts +27 -0
- package/dist/importer/import-progress.js +38 -0
- package/dist/importer/import-runs.d.ts +43 -0
- package/dist/importer/import-runs.js +141 -0
- package/dist/importer/import-sessions.js +9 -9
- package/dist/importer/importer.d.ts +2 -1
- package/dist/importer/importer.js +2 -2
- package/dist/importer/map-suggestions.d.ts +39 -0
- package/dist/importer/map-suggestions.js +133 -0
- package/dist/importer/opencode-import.d.ts +2 -1
- package/dist/{adapters/pi/conversation.d.ts → importer/pi-conversation.d.ts} +1 -1
- package/dist/importer/profile-import.js +3 -2
- package/dist/importer/run-import.d.ts +21 -3
- package/dist/importer/run-import.js +82 -2
- package/dist/importer/session-loader.d.ts +1 -1
- package/dist/importer/web-import-api.d.ts +3 -0
- package/dist/importer/web-import-api.js +3 -0
- package/dist/importer/web-import-jobs.js +1 -0
- package/dist/index.js +9 -4
- package/dist/services/ai/live-model-choice.d.ts +4 -1
- package/dist/services/ai/live-model-choice.js +18 -0
- package/dist/services/api-handlers.d.ts +6 -1
- package/dist/services/api-handlers.js +71 -62
- package/dist/services/backfill-state.d.ts +30 -0
- package/dist/services/backfill-state.js +47 -0
- package/dist/services/capture-diagnostics.d.ts +2 -8
- package/dist/services/capture-diagnostics.js +3 -49
- package/dist/services/global-config-writer.d.ts +2 -0
- package/dist/services/global-config-writer.js +37 -6
- package/dist/services/global-version.d.ts +31 -0
- package/dist/services/global-version.js +78 -0
- package/dist/services/memory-key-source.d.ts +35 -0
- package/dist/services/memory-key-source.js +123 -0
- package/dist/services/package-version.d.ts +2 -0
- package/dist/services/package-version.js +21 -0
- package/dist/services/private-path.d.ts +13 -0
- package/dist/services/private-path.js +54 -0
- package/dist/services/settings-snapshot.d.ts +17 -0
- package/dist/services/settings-snapshot.js +51 -14
- package/dist/services/turso/vector-search.d.ts +1 -1
- package/dist/services/turso/vector-search.js +5 -2
- package/dist/services/web-server.d.ts +2 -0
- package/dist/services/web-server.js +74 -1
- package/dist/web/assets/index-DvZhF1BJ.css +2 -0
- package/dist/web/assets/index-jqmX5i_i.js +124 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -1
- package/dist/web/assets/index-B-_gMSn9.js +0 -124
- package/dist/web/assets/index-D5ak5KsI.css +0 -2
- /package/dist/{adapters/pi/conversation.js → importer/pi-conversation.js} +0 -0
|
@@ -33,12 +33,21 @@ function getManualProviderStatus(config) {
|
|
|
33
33
|
issues,
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
/** The host chose the external API: it is the primary call, so there is no fallback. */
|
|
37
|
+
function externalChoiceStatus(config) {
|
|
38
|
+
const manual = getManualProviderStatus(config);
|
|
39
|
+
return manual.ready
|
|
40
|
+
? { ready: true, mode: "manual", issues: [] }
|
|
41
|
+
: { ready: false, issues: manual.issues };
|
|
42
|
+
}
|
|
36
43
|
export function getAutoCaptureProviderStatus(config) {
|
|
37
44
|
const hasOpencodeProvider = hasValue(config.opencodeProvider);
|
|
38
45
|
const hasOpencodeModel = hasValue(config.opencodeModel);
|
|
39
46
|
if (config.opencodeModel?.trim() === "inherit") {
|
|
40
47
|
return { ready: true, mode: "session", issues: [] };
|
|
41
48
|
}
|
|
49
|
+
if (config.opencodeModel?.trim() === "external")
|
|
50
|
+
return externalChoiceStatus(config);
|
|
42
51
|
if (hasOpencodeProvider && hasOpencodeModel) {
|
|
43
52
|
return { ready: true, mode: "opencode", issues: [] };
|
|
44
53
|
}
|
|
@@ -70,6 +79,10 @@ export function resolveOpencodeHostModel(config) {
|
|
|
70
79
|
export function resolvePiLiveModel(config) {
|
|
71
80
|
if (config.piModel?.trim() === "inherit")
|
|
72
81
|
return { kind: "session" };
|
|
82
|
+
if (config.piModel?.trim() === "external") {
|
|
83
|
+
const status = externalChoiceStatus(config);
|
|
84
|
+
return status.ready ? { kind: "manual" } : { kind: "unready", issues: status.issues };
|
|
85
|
+
}
|
|
73
86
|
if (hasValue(config.piProvider) && hasValue(config.piModel)) {
|
|
74
87
|
return { kind: "pi", provider: config.piProvider.trim(), model: config.piModel.trim() };
|
|
75
88
|
}
|
|
@@ -84,3 +97,8 @@ export function resolvePiLiveModel(config) {
|
|
|
84
97
|
export function isExternalModelReady(config) {
|
|
85
98
|
return getManualProviderStatus(config).ready;
|
|
86
99
|
}
|
|
100
|
+
/** Why the external API cannot be used as a host's chosen model, or an empty list. */
|
|
101
|
+
export function externalModelIssues(config) {
|
|
102
|
+
const manual = getManualProviderStatus(config);
|
|
103
|
+
return manual.ready ? [] : manual.issues;
|
|
104
|
+
}
|
|
@@ -41,7 +41,7 @@ interface PaginatedResponse<T> {
|
|
|
41
41
|
export declare function handleListTags(): Promise<ApiResponse<{
|
|
42
42
|
project: TagInfo[];
|
|
43
43
|
}>>;
|
|
44
|
-
export declare function handleListMemories(tag?: string, page?: number, pageSize?: number, includePrompts?: boolean): Promise<ApiResponse<PaginatedResponse<Memory | any>>>;
|
|
44
|
+
export declare function handleListMemories(tag?: string, page?: number, pageSize?: number, includePrompts?: boolean, keyword?: string): Promise<ApiResponse<PaginatedResponse<Memory | any>>>;
|
|
45
45
|
export declare function handleAddMemory(data: {
|
|
46
46
|
content: string;
|
|
47
47
|
containerTag: string;
|
|
@@ -178,6 +178,11 @@ interface MigrationProgress {
|
|
|
178
178
|
errors: string[];
|
|
179
179
|
}
|
|
180
180
|
export declare function handleGetTagMigrationProgress(): Promise<ApiResponse<MigrationProgress>>;
|
|
181
|
+
/**
|
|
182
|
+
* Tag the memories that have no tags, a few per request. Only untagged
|
|
183
|
+
* memories are touched: tagged ones keep their vectors. A memory that fails
|
|
184
|
+
* is recorded and passed over, so one bad row cannot stall the run.
|
|
185
|
+
*/
|
|
181
186
|
export declare function handleRunTagMigrationBatch(batchSize?: number): Promise<ApiResponse<{
|
|
182
187
|
processed: number;
|
|
183
188
|
total: number;
|
|
@@ -98,7 +98,7 @@ export async function handleListTags() {
|
|
|
98
98
|
return { success: false, error: String(error) };
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
export async function handleListMemories(tag, page = 1, pageSize = 20, includePrompts = true) {
|
|
101
|
+
export async function handleListMemories(tag, page = 1, pageSize = 20, includePrompts = true, keyword) {
|
|
102
102
|
try {
|
|
103
103
|
await ensureTursoReady();
|
|
104
104
|
// Listing only reads SQLite rows; no vector ops happen here.
|
|
@@ -151,11 +151,21 @@ export async function handleListMemories(tag, page = 1, pageSize = 20, includePr
|
|
|
151
151
|
isPinned: r.is_pinned === 1,
|
|
152
152
|
};
|
|
153
153
|
});
|
|
154
|
-
|
|
154
|
+
// A keyword filter keeps memories carrying that keyword (case-insensitive)
|
|
155
|
+
// and only the prompts linked to them.
|
|
156
|
+
const wanted = keyword?.trim().toLowerCase();
|
|
157
|
+
const keptMemories = wanted
|
|
158
|
+
? memoriesWithType.filter((m) => m.tags.some((t) => t.toLowerCase() === wanted))
|
|
159
|
+
: memoriesWithType;
|
|
160
|
+
const keptIds = new Set(keptMemories.map((m) => m.id));
|
|
161
|
+
let timeline = keptMemories;
|
|
155
162
|
if (includePrompts) {
|
|
156
163
|
const projectPath = tag ? await getProjectPathFromTag(tag) : undefined;
|
|
157
164
|
const prompts = await userPromptManager.getCapturedPrompts(projectPath);
|
|
158
|
-
const
|
|
165
|
+
const visiblePrompts = wanted
|
|
166
|
+
? prompts.filter((p) => p.linkedMemoryId && keptIds.has(p.linkedMemoryId))
|
|
167
|
+
: prompts;
|
|
168
|
+
const promptsWithType = visiblePrompts.map((p) => ({
|
|
159
169
|
type: "prompt",
|
|
160
170
|
id: p.id,
|
|
161
171
|
sessionId: p.sessionId,
|
|
@@ -164,7 +174,7 @@ export async function handleListMemories(tag, page = 1, pageSize = 20, includePr
|
|
|
164
174
|
projectPath: p.projectPath,
|
|
165
175
|
linkedMemoryId: p.linkedMemoryId,
|
|
166
176
|
}));
|
|
167
|
-
timeline = [...
|
|
177
|
+
timeline = [...keptMemories, ...promptsWithType];
|
|
168
178
|
}
|
|
169
179
|
const linkedPairs = new Map();
|
|
170
180
|
const standalone = [];
|
|
@@ -1181,17 +1191,25 @@ export async function handleDetectTagMigration() {
|
|
|
1181
1191
|
return { success: false, error: String(error) };
|
|
1182
1192
|
}
|
|
1183
1193
|
}
|
|
1184
|
-
const
|
|
1194
|
+
const idleMigration = () => ({
|
|
1185
1195
|
processed: 0,
|
|
1186
1196
|
total: 0,
|
|
1187
1197
|
currentBatch: 0,
|
|
1188
1198
|
totalBatches: 0,
|
|
1189
1199
|
isComplete: true,
|
|
1190
1200
|
errors: [],
|
|
1191
|
-
};
|
|
1201
|
+
});
|
|
1202
|
+
let migrationProgress = idleMigration();
|
|
1203
|
+
/** The untagged memories this migration run covers, fixed when the run starts. */
|
|
1204
|
+
let migrationQueue = [];
|
|
1192
1205
|
export async function handleGetTagMigrationProgress() {
|
|
1193
1206
|
return { success: true, data: migrationProgress };
|
|
1194
1207
|
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Tag the memories that have no tags, a few per request. Only untagged
|
|
1210
|
+
* memories are touched: tagged ones keep their vectors. A memory that fails
|
|
1211
|
+
* is recorded and passed over, so one bad row cannot stall the run.
|
|
1212
|
+
*/
|
|
1195
1213
|
export async function handleRunTagMigrationBatch(batchSize = 5) {
|
|
1196
1214
|
try {
|
|
1197
1215
|
await ensureTursoReady();
|
|
@@ -1202,77 +1220,68 @@ export async function handleRunTagMigrationBatch(batchSize = 5) {
|
|
|
1202
1220
|
iterationTimeout: 30000,
|
|
1203
1221
|
});
|
|
1204
1222
|
const provider = AIProviderFactory.createProvider(CONFIG.memoryProvider, providerConfig);
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1223
|
+
if (migrationProgress.isComplete) {
|
|
1224
|
+
migrationQueue = [];
|
|
1225
|
+
for (const shard of await tursoShardManager.getAllShards("project", "")) {
|
|
1226
|
+
const db = await tursoConnectionManager.getConnection(shard.dbPath);
|
|
1227
|
+
const rows = await db.all("SELECT id FROM memories WHERE tags IS NULL OR tags = '' ORDER BY id");
|
|
1228
|
+
for (const row of rows)
|
|
1229
|
+
migrationQueue.push({ id: String(row.id), dbPath: shard.dbPath });
|
|
1212
1230
|
}
|
|
1231
|
+
migrationProgress = {
|
|
1232
|
+
...idleMigration(),
|
|
1233
|
+
total: migrationQueue.length,
|
|
1234
|
+
totalBatches: Math.ceil(migrationQueue.length / batchSize),
|
|
1235
|
+
isComplete: migrationQueue.length === 0,
|
|
1236
|
+
};
|
|
1213
1237
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
migrationProgress.totalBatches = Math.ceil(allMemories.length / batchSize);
|
|
1217
|
-
migrationProgress.isComplete = false;
|
|
1218
|
-
}
|
|
1219
|
-
const startIdx = migrationProgress.processed;
|
|
1220
|
-
const endIdx = Math.min(startIdx + batchSize, allMemories.length);
|
|
1221
|
-
for (let i = startIdx; i < endIdx; i++) {
|
|
1222
|
-
const item = allMemories[i];
|
|
1223
|
-
if (!item)
|
|
1224
|
-
continue;
|
|
1225
|
-
const { memory: m, shard } = item;
|
|
1226
|
-
const db = await tursoConnectionManager.getConnection(shard.dbPath);
|
|
1238
|
+
const batch = migrationQueue.slice(migrationProgress.processed, migrationProgress.processed + batchSize);
|
|
1239
|
+
for (const item of batch) {
|
|
1227
1240
|
try {
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
properties: { tags: { type: "array", items: { type: "string" } } },
|
|
1244
|
-
required: ["tags"],
|
|
1245
|
-
},
|
|
1241
|
+
const db = await tursoConnectionManager.getConnection(item.dbPath);
|
|
1242
|
+
const m = await db.get("SELECT * FROM memories WHERE id = ?", [item.id]);
|
|
1243
|
+
// Tagged or deleted since the run started: nothing to do.
|
|
1244
|
+
if (!m || (m.tags && String(m.tags).trim()))
|
|
1245
|
+
continue;
|
|
1246
|
+
const prompt = `Generate 2-4 short technical tags for this memory content:\n\n${m.content}\n\nReturn ONLY a comma-separated list of tags.`;
|
|
1247
|
+
const result = await provider.executeToolCall("You are a technical tagger.", prompt, {
|
|
1248
|
+
type: "function",
|
|
1249
|
+
function: {
|
|
1250
|
+
name: "save_tags",
|
|
1251
|
+
description: "Save generated tags",
|
|
1252
|
+
parameters: {
|
|
1253
|
+
type: "object",
|
|
1254
|
+
properties: { tags: { type: "array", items: { type: "string" } } },
|
|
1255
|
+
required: ["tags"],
|
|
1246
1256
|
},
|
|
1247
|
-
},
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
}
|
|
1257
|
+
},
|
|
1258
|
+
}, `migration_${m.id}`);
|
|
1259
|
+
const tags = Array.isArray(result.data?.tags)
|
|
1260
|
+
? result.data.tags.map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
1261
|
+
: [];
|
|
1262
|
+
if (!result.success || tags.length === 0) {
|
|
1263
|
+
throw new Error(result.error ?? "The model returned no tags");
|
|
1255
1264
|
}
|
|
1256
1265
|
const vector = await embeddingService.embedWithTimeout(m.content, { task: "document" });
|
|
1257
|
-
const tagsVector =
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
await tursoVectorSearch.updateVector(db, m.id, vector, tagsVector);
|
|
1263
|
-
migrationProgress.processed++;
|
|
1266
|
+
const tagsVector = await embeddingService.embedWithTimeout(formatTagsForEmbedding(tags), {
|
|
1267
|
+
task: "document",
|
|
1268
|
+
});
|
|
1269
|
+
// One statement, so a memory never ends up tagged with stale vectors.
|
|
1270
|
+
await tursoVectorSearch.updateVector(db, m.id, vector, tagsVector, tags.join(","));
|
|
1264
1271
|
}
|
|
1265
1272
|
catch (e) {
|
|
1266
1273
|
const errorMsg = String(e);
|
|
1267
1274
|
migrationProgress.errors.push(errorMsg);
|
|
1268
|
-
log("Migration error for memory", { id:
|
|
1275
|
+
log("Migration error for memory", { id: item.id, error: errorMsg });
|
|
1276
|
+
}
|
|
1277
|
+
finally {
|
|
1278
|
+
migrationProgress.processed++;
|
|
1269
1279
|
}
|
|
1270
1280
|
}
|
|
1271
1281
|
migrationProgress.currentBatch++;
|
|
1272
1282
|
const hasMore = migrationProgress.processed < migrationProgress.total;
|
|
1273
|
-
if (!hasMore)
|
|
1283
|
+
if (!hasMore)
|
|
1274
1284
|
migrationProgress.isComplete = true;
|
|
1275
|
-
}
|
|
1276
1285
|
return {
|
|
1277
1286
|
success: true,
|
|
1278
1287
|
data: { processed: migrationProgress.processed, total: migrationProgress.total, hasMore },
|
|
@@ -14,10 +14,40 @@ export type BackfillStatus = {
|
|
|
14
14
|
error: string | null;
|
|
15
15
|
updatedAt: number;
|
|
16
16
|
};
|
|
17
|
+
export type UnresolvedDirectory = {
|
|
18
|
+
directory: string;
|
|
19
|
+
sessions: number;
|
|
20
|
+
};
|
|
21
|
+
export declare const MAX_UNRESOLVED_DIRECTORIES = 200;
|
|
17
22
|
/** Record a fixed per-host cutoff before the first import. */
|
|
18
23
|
export declare function getBackfillCutoff(host: BackfillHost, now?: number): Promise<number>;
|
|
19
24
|
/** Read progress without creating a store or a ledger table. */
|
|
20
25
|
export declare function readBackfillStatus(host: BackfillHost): Promise<BackfillStatus | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Group unresolved sessions by directory, most sessions first, capped so the
|
|
28
|
+
* row stays small. Sessions with no recorded directory are left out.
|
|
29
|
+
*/
|
|
30
|
+
export declare function summarizeUnresolvedDirectories(sessions: ReadonlyArray<{
|
|
31
|
+
directory: string | null;
|
|
32
|
+
sessions?: number;
|
|
33
|
+
}>): UnresolvedDirectory[];
|
|
34
|
+
/** Unresolved directories of an import report with session counts. */
|
|
35
|
+
export declare function unresolvedDirectoriesOf(report: {
|
|
36
|
+
unresolvedProjects?: ReadonlyArray<{
|
|
37
|
+
directory: string;
|
|
38
|
+
sessions: number;
|
|
39
|
+
}>;
|
|
40
|
+
unresolvableSessions: ReadonlyArray<{
|
|
41
|
+
cwd: string | null;
|
|
42
|
+
}>;
|
|
43
|
+
}): UnresolvedDirectory[];
|
|
44
|
+
/**
|
|
45
|
+
* Save the unresolved directories of a host's latest listing or run. They
|
|
46
|
+
* live in their own table, so recording them never sets a backfill cutoff.
|
|
47
|
+
*/
|
|
48
|
+
export declare function recordUnresolvedDirectories(host: BackfillHost, directories: UnresolvedDirectory[], now?: number): Promise<void>;
|
|
49
|
+
/** A host's latest unresolved directories, without creating a store or table. */
|
|
50
|
+
export declare function readUnresolvedDirectories(host: BackfillHost): Promise<UnresolvedDirectory[]>;
|
|
21
51
|
/** Save only numeric counts and a redacted error, never report units or prompts. */
|
|
22
52
|
export declare function updateBackfillStatus(host: BackfillHost, update: Pick<BackfillStatus, "state" | "model" | "counts"> & {
|
|
23
53
|
error?: unknown;
|
|
@@ -4,6 +4,7 @@ import { CONFIG } from "../config.js";
|
|
|
4
4
|
import { tursoConnectionManager } from "./turso/connection-manager.js";
|
|
5
5
|
import { safeHealthError } from "./safe-health-error.js";
|
|
6
6
|
const importLedgerDbPath = () => join(CONFIG.storagePath, "import-ledger.db");
|
|
7
|
+
export const MAX_UNRESOLVED_DIRECTORIES = 200;
|
|
7
8
|
const emptyCounts = () => ({ imported: 0, skipped: 0, failed: 0, pending: 0, unresolved: 0 });
|
|
8
9
|
async function table() {
|
|
9
10
|
const db = await tursoConnectionManager.getConnection(importLedgerDbPath());
|
|
@@ -48,6 +49,52 @@ export async function readBackfillStatus(host) {
|
|
|
48
49
|
updatedAt: Number(row.updated_at),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Group unresolved sessions by directory, most sessions first, capped so the
|
|
54
|
+
* row stays small. Sessions with no recorded directory are left out.
|
|
55
|
+
*/
|
|
56
|
+
export function summarizeUnresolvedDirectories(sessions) {
|
|
57
|
+
const byDirectory = new Map();
|
|
58
|
+
for (const item of sessions) {
|
|
59
|
+
if (!item.directory)
|
|
60
|
+
continue;
|
|
61
|
+
byDirectory.set(item.directory, (byDirectory.get(item.directory) ?? 0) + (item.sessions ?? 1));
|
|
62
|
+
}
|
|
63
|
+
return [...byDirectory]
|
|
64
|
+
.map(([directory, count]) => ({ directory, sessions: count }))
|
|
65
|
+
.sort((a, b) => b.sessions - a.sessions || a.directory.localeCompare(b.directory))
|
|
66
|
+
.slice(0, MAX_UNRESOLVED_DIRECTORIES);
|
|
67
|
+
}
|
|
68
|
+
/** Unresolved directories of an import report with session counts. */
|
|
69
|
+
export function unresolvedDirectoriesOf(report) {
|
|
70
|
+
return summarizeUnresolvedDirectories([
|
|
71
|
+
...(report.unresolvedProjects ?? []),
|
|
72
|
+
...report.unresolvableSessions.map((session) => ({ directory: session.cwd })),
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Save the unresolved directories of a host's latest listing or run. They
|
|
77
|
+
* live in their own table, so recording them never sets a backfill cutoff.
|
|
78
|
+
*/
|
|
79
|
+
export async function recordUnresolvedDirectories(host, directories, now = Date.now()) {
|
|
80
|
+
const db = await tursoConnectionManager.getConnection(importLedgerDbPath());
|
|
81
|
+
await db.run(`CREATE TABLE IF NOT EXISTS unresolved_directories (
|
|
82
|
+
host TEXT PRIMARY KEY, directories TEXT NOT NULL, updated_at INTEGER NOT NULL
|
|
83
|
+
)`);
|
|
84
|
+
await db.run(`INSERT INTO unresolved_directories (host, directories, updated_at) VALUES (?, ?, ?)
|
|
85
|
+
ON CONFLICT(host) DO UPDATE SET directories = excluded.directories, updated_at = excluded.updated_at`, [host, JSON.stringify(directories.slice(0, MAX_UNRESOLVED_DIRECTORIES)), now]);
|
|
86
|
+
}
|
|
87
|
+
/** A host's latest unresolved directories, without creating a store or table. */
|
|
88
|
+
export async function readUnresolvedDirectories(host) {
|
|
89
|
+
if (!existsSync(importLedgerDbPath()))
|
|
90
|
+
return [];
|
|
91
|
+
const db = await tursoConnectionManager.getConnection(importLedgerDbPath());
|
|
92
|
+
const exists = await db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'unresolved_directories'");
|
|
93
|
+
if (!exists)
|
|
94
|
+
return [];
|
|
95
|
+
const row = await db.get("SELECT directories FROM unresolved_directories WHERE host = ?", [host]);
|
|
96
|
+
return row ? JSON.parse(String(row.directories)) : [];
|
|
97
|
+
}
|
|
51
98
|
/** Save only numeric counts and a redacted error, never report units or prompts. */
|
|
52
99
|
export async function updateBackfillStatus(host, update) {
|
|
53
100
|
const counts = Object.fromEntries(Object.keys(emptyCounts()).map((key) => [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CaptureAttemptDiagnostics, CaptureAttemptOutcome, CaptureExtractionPath, CaptureFailureReason } from "../core/host.js";
|
|
2
2
|
import type { MemoryHost, MemorySourceType } from "../types/index.js";
|
|
3
|
+
import { restrictToCurrentUser } from "./private-path.js";
|
|
3
4
|
/**
|
|
4
5
|
* The config fields this module reads. Callers pass `CONFIG`; tests pass a
|
|
5
6
|
* plain object, so no import of `../config.js` is needed here.
|
|
@@ -52,14 +53,8 @@ export declare function getTraceDirectory(): string;
|
|
|
52
53
|
* that do not match the trace name pattern are never touched.
|
|
53
54
|
*/
|
|
54
55
|
export declare function pruneTraces(config: CaptureDiagnosticsConfig, now?: Date): number;
|
|
55
|
-
type WindowsAclRunner = (command: string, args: string[], options: {
|
|
56
|
-
env: NodeJS.ProcessEnv;
|
|
57
|
-
timeout: number;
|
|
58
|
-
windowsHide: boolean;
|
|
59
|
-
stdio: "ignore";
|
|
60
|
-
}) => void;
|
|
61
56
|
/** Restrict a trace path to the current user on both Windows and POSIX. */
|
|
62
|
-
export declare
|
|
57
|
+
export declare const protectTracePath: typeof restrictToCurrentUser;
|
|
63
58
|
/**
|
|
64
59
|
* Append one trace entry to the day's JSON Lines file. A single append per
|
|
65
60
|
* entry keeps lines whole when Pi and OpenCode write at the same time.
|
|
@@ -72,4 +67,3 @@ export declare function writeTraceEntry(record: CaptureAttemptRecord, diagnostic
|
|
|
72
67
|
export declare function emitCaptureAttempt(record: CaptureAttemptRecord, diagnostics: CaptureAttemptDiagnostics, config: CaptureDiagnosticsConfig, now?: Date): void;
|
|
73
68
|
/** Test hook: forget which day the last trace was written. */
|
|
74
69
|
export declare function resetTraceStateForTests(): void;
|
|
75
|
-
export {};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { appendFileSync, chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, } from "fs";
|
|
1
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync } from "fs";
|
|
3
2
|
import { join } from "path";
|
|
4
3
|
import { getLogDirPath } from "./log-path.js";
|
|
5
4
|
import { log } from "./logger.js";
|
|
5
|
+
import { restrictToCurrentUser } from "./private-path.js";
|
|
6
6
|
import { stripPrivateContent } from "./privacy.js";
|
|
7
7
|
export const DEFAULT_CAPTURE_TRACE_RETENTION_DAYS = 7;
|
|
8
8
|
const TRACE_SCHEMA_VERSION = 1;
|
|
@@ -113,54 +113,8 @@ export function pruneTraces(config, now = new Date()) {
|
|
|
113
113
|
return removed;
|
|
114
114
|
}
|
|
115
115
|
let lastTraceDate = null;
|
|
116
|
-
const WINDOWS_TRACE_ACL = `$ErrorActionPreference = 'Stop'
|
|
117
|
-
$path = $env:OMMS_TRACE_ACL_PATH
|
|
118
|
-
$isDirectory = [System.IO.Directory]::Exists($path)
|
|
119
|
-
$acl = if ($isDirectory) {
|
|
120
|
-
[System.IO.Directory]::GetAccessControl($path)
|
|
121
|
-
} else {
|
|
122
|
-
[System.IO.File]::GetAccessControl($path)
|
|
123
|
-
}
|
|
124
|
-
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
125
|
-
$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier])
|
|
126
|
-
if ($owner.Value -ne $currentUser.Value) {
|
|
127
|
-
throw 'Trace path has a different owner'
|
|
128
|
-
}
|
|
129
|
-
$acl.SetAccessRuleProtection($true, $false)
|
|
130
|
-
foreach ($rule in @($acl.Access)) {
|
|
131
|
-
[void]$acl.RemoveAccessRuleSpecific($rule)
|
|
132
|
-
}
|
|
133
|
-
$inheritance = if ($isDirectory) {
|
|
134
|
-
[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
135
|
-
} else {
|
|
136
|
-
[System.Security.AccessControl.InheritanceFlags]::None
|
|
137
|
-
}
|
|
138
|
-
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
|
|
139
|
-
$currentUser,
|
|
140
|
-
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
141
|
-
$inheritance,
|
|
142
|
-
[System.Security.AccessControl.PropagationFlags]::None,
|
|
143
|
-
[System.Security.AccessControl.AccessControlType]::Allow
|
|
144
|
-
)
|
|
145
|
-
$acl.AddAccessRule($rule)
|
|
146
|
-
if ($isDirectory) {
|
|
147
|
-
[System.IO.Directory]::SetAccessControl($path, $acl)
|
|
148
|
-
} else {
|
|
149
|
-
[System.IO.File]::SetAccessControl($path, $acl)
|
|
150
|
-
}`;
|
|
151
116
|
/** Restrict a trace path to the current user on both Windows and POSIX. */
|
|
152
|
-
export
|
|
153
|
-
if (platform !== "win32") {
|
|
154
|
-
chmodSync(path, mode);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_TRACE_ACL], {
|
|
158
|
-
env: { ...process.env, OMMS_TRACE_ACL_PATH: path },
|
|
159
|
-
timeout: 10_000,
|
|
160
|
-
windowsHide: true,
|
|
161
|
-
stdio: "ignore",
|
|
162
|
-
});
|
|
163
|
-
}
|
|
117
|
+
export const protectTracePath = restrictToCurrentUser;
|
|
164
118
|
/**
|
|
165
119
|
* Append one trace entry to the day's JSON Lines file. A single append per
|
|
166
120
|
* entry keeps lines whole when Pi and OpenCode write at the same time.
|
|
@@ -15,7 +15,39 @@ const keys = new Set([
|
|
|
15
15
|
"opencodeBackfillModel",
|
|
16
16
|
"piBackfillModel",
|
|
17
17
|
"webServerAutoStart",
|
|
18
|
+
"memoryProvider",
|
|
19
|
+
"memoryApiUrl",
|
|
20
|
+
"memoryModel",
|
|
21
|
+
"memoryApiKey",
|
|
22
|
+
"importPathMaps",
|
|
18
23
|
]);
|
|
24
|
+
const MEMORY_PROVIDERS = [
|
|
25
|
+
"openai-chat",
|
|
26
|
+
"openai-responses",
|
|
27
|
+
"anthropic",
|
|
28
|
+
"minimax",
|
|
29
|
+
"orcarouter",
|
|
30
|
+
"google-gemini",
|
|
31
|
+
];
|
|
32
|
+
/** The page may point `memoryApiKey` at a source, never store the key itself. */
|
|
33
|
+
export function isSecretReference(value) {
|
|
34
|
+
return (typeof value === "string" && /^(env:\/\/[A-Za-z_][A-Za-z0-9_]*|file:\/\/.+)$/.test(value.trim()));
|
|
35
|
+
}
|
|
36
|
+
function isValidEdit(key, value) {
|
|
37
|
+
if (key === "captureTrace" || key === "autoBackfill" || key === "webServerAutoStart") {
|
|
38
|
+
return typeof value === "boolean";
|
|
39
|
+
}
|
|
40
|
+
if (key.endsWith("RetentionDays"))
|
|
41
|
+
return Number.isSafeInteger(value) && value >= 1;
|
|
42
|
+
if (key === "memoryApiKey")
|
|
43
|
+
return isSecretReference(value);
|
|
44
|
+
if (key === "memoryProvider")
|
|
45
|
+
return MEMORY_PROVIDERS.includes(value);
|
|
46
|
+
// Entries are checked by the startup validation below.
|
|
47
|
+
if (key === "importPathMaps")
|
|
48
|
+
return Array.isArray(value);
|
|
49
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
50
|
+
}
|
|
19
51
|
export class ConfigConflictError extends Error {
|
|
20
52
|
status = 409;
|
|
21
53
|
constructor() {
|
|
@@ -50,12 +82,11 @@ export async function writeGlobalConfigKeys(edits, expectedRevision) {
|
|
|
50
82
|
for (const [key, value] of Object.entries(edits)) {
|
|
51
83
|
if (!keys.has(key))
|
|
52
84
|
throw new Error(`Setting ${key} cannot be edited here`);
|
|
53
|
-
if (key
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
?
|
|
57
|
-
:
|
|
58
|
-
throw new Error(`Invalid ${key} setting`);
|
|
85
|
+
if (!isValidEdit(key, value)) {
|
|
86
|
+
// Never echo the value: a rejected memoryApiKey may be a literal key.
|
|
87
|
+
throw new Error(key === "memoryApiKey"
|
|
88
|
+
? "memoryApiKey must be an env:// or file:// reference"
|
|
89
|
+
: `Invalid ${key} setting`);
|
|
59
90
|
}
|
|
60
91
|
}
|
|
61
92
|
const wasQueued = pending > 0;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface GlobalCommandVersion {
|
|
2
|
+
/** The version the global `om-memory-system --version` printed, or null when not installed. */
|
|
3
|
+
version: string | null;
|
|
4
|
+
path: string | null;
|
|
5
|
+
error?: "timeout" | "failed";
|
|
6
|
+
}
|
|
7
|
+
type Runner = (file: string, args: string[], options: {
|
|
8
|
+
timeout: number;
|
|
9
|
+
windowsHide: boolean;
|
|
10
|
+
shell: false;
|
|
11
|
+
}) => Promise<string>;
|
|
12
|
+
/** The first `om-memory-system` on PATH; on Windows npm installs a `.cmd` wrapper. */
|
|
13
|
+
export declare function findGlobalCommand(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* The command and arguments that print the version without a user-controlled
|
|
16
|
+
* shell. A Windows `.cmd` wrapper can only run through cmd.exe, so it runs
|
|
17
|
+
* through the system's own cmd.exe with fixed arguments.
|
|
18
|
+
*/
|
|
19
|
+
export declare function versionInvocation(path: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): {
|
|
20
|
+
file: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
};
|
|
23
|
+
/** Look up the global command's version, at most once every 10 minutes. */
|
|
24
|
+
export declare function globalCommandVersion(options?: {
|
|
25
|
+
now?: () => number;
|
|
26
|
+
runner?: Runner;
|
|
27
|
+
find?: () => string | null;
|
|
28
|
+
platform?: NodeJS.Platform;
|
|
29
|
+
}): Promise<GlobalCommandVersion>;
|
|
30
|
+
export declare function resetGlobalVersionCache(): void;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { accessSync, constants } from "node:fs";
|
|
3
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
4
|
+
const TIMEOUT_MS = 3_000;
|
|
5
|
+
const CACHE_MS = 10 * 60_000;
|
|
6
|
+
const run = (file, args, options) => new Promise((resolve, reject) => {
|
|
7
|
+
execFile(file, args, { ...options, encoding: "utf8" }, (error, stdout) => error ? reject(error) : resolve(String(stdout)));
|
|
8
|
+
});
|
|
9
|
+
function executable(path) {
|
|
10
|
+
try {
|
|
11
|
+
accessSync(path, constants.X_OK);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The first `om-memory-system` on PATH; on Windows npm installs a `.cmd` wrapper. */
|
|
19
|
+
export function findGlobalCommand(env = process.env, platform = process.platform, exists = executable) {
|
|
20
|
+
const names = platform === "win32" ? ["om-memory-system.exe", "om-memory-system.cmd"] : ["om-memory-system"];
|
|
21
|
+
const pathValue = env.PATH ?? env.Path ?? "";
|
|
22
|
+
for (const raw of pathValue.split(platform === "win32" ? ";" : delimiter)) {
|
|
23
|
+
const entry = raw.trim().replace(/^"(.*)"$/, "$1");
|
|
24
|
+
if (!entry || !(platform === "win32" ? /^[A-Za-z]:[\\/]|^\\\\/.test(entry) : isAbsolute(entry)))
|
|
25
|
+
continue;
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
const candidate = platform === "win32" ? `${entry.replace(/[\\/]+$/, "")}\\${name}` : join(entry, name);
|
|
28
|
+
if (exists(candidate))
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The command and arguments that print the version without a user-controlled
|
|
36
|
+
* shell. A Windows `.cmd` wrapper can only run through cmd.exe, so it runs
|
|
37
|
+
* through the system's own cmd.exe with fixed arguments.
|
|
38
|
+
*/
|
|
39
|
+
export function versionInvocation(path, platform = process.platform, env = process.env) {
|
|
40
|
+
if (platform === "win32" && path.toLowerCase().endsWith(".cmd")) {
|
|
41
|
+
const cmd = env.SystemRoot ? `${env.SystemRoot}\\System32\\cmd.exe` : "cmd.exe";
|
|
42
|
+
return { file: cmd, args: ["/d", "/s", "/c", `"${path}" --version`] };
|
|
43
|
+
}
|
|
44
|
+
return { file: path, args: ["--version"] };
|
|
45
|
+
}
|
|
46
|
+
let cached = null;
|
|
47
|
+
/** Look up the global command's version, at most once every 10 minutes. */
|
|
48
|
+
export async function globalCommandVersion(options = {}) {
|
|
49
|
+
const now = (options.now ?? Date.now)();
|
|
50
|
+
if (cached && now - cached.at < CACHE_MS)
|
|
51
|
+
return cached.value;
|
|
52
|
+
const path = (options.find ?? (() => findGlobalCommand()))();
|
|
53
|
+
let value;
|
|
54
|
+
if (!path) {
|
|
55
|
+
value = { version: null, path: null };
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const { file, args } = versionInvocation(path, options.platform);
|
|
59
|
+
try {
|
|
60
|
+
const output = await (options.runner ?? run)(file, args, {
|
|
61
|
+
timeout: TIMEOUT_MS,
|
|
62
|
+
windowsHide: true,
|
|
63
|
+
shell: false,
|
|
64
|
+
});
|
|
65
|
+
const version = /\d+\.\d+\.\d+(?:[-+][\w.]+)?/.exec(output)?.[0] ?? null;
|
|
66
|
+
value = version ? { version, path } : { version: null, path, error: "failed" };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const killed = error.killed;
|
|
70
|
+
value = { version: null, path, error: killed ? "timeout" : "failed" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
cached = { at: now, value };
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
export function resetGlobalVersionCache() {
|
|
77
|
+
cached = null;
|
|
78
|
+
}
|