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.
Files changed (82) hide show
  1. package/README.md +21 -7
  2. package/dist/adapters/opencode/backfill-models.js +5 -1
  3. package/dist/adapters/opencode/backfill-startup.d.ts +6 -0
  4. package/dist/adapters/opencode/backfill-startup.js +5 -0
  5. package/dist/adapters/opencode/import-command.js +1 -0
  6. package/dist/adapters/opencode/user-prompt.d.ts +2 -1
  7. package/dist/adapters/opencode/user-prompt.js +2 -9
  8. package/dist/adapters/pi/backfill-models.d.ts +4 -0
  9. package/dist/adapters/pi/backfill-models.js +3 -0
  10. package/dist/adapters/pi/capture.d.ts +1 -1
  11. package/dist/adapters/pi/capture.js +1 -1
  12. package/dist/adapters/pi/extension.js +7 -0
  13. package/dist/adapters/pi/import-command.js +1 -0
  14. package/dist/cli/index.js +13 -1
  15. package/dist/config.d.ts +11 -4
  16. package/dist/config.js +29 -6
  17. package/dist/core/internal-prompt.d.ts +6 -0
  18. package/dist/core/internal-prompt.js +11 -0
  19. package/dist/importer/auto-backfill.d.ts +5 -0
  20. package/dist/importer/auto-backfill.js +29 -10
  21. package/dist/importer/backfill-controls.d.ts +37 -0
  22. package/dist/importer/backfill-controls.js +119 -0
  23. package/dist/importer/backfill-lock.d.ts +1 -0
  24. package/dist/importer/backfill-lock.js +2 -2
  25. package/dist/importer/backfill-model.d.ts +1 -1
  26. package/dist/importer/backfill-model.js +2 -0
  27. package/dist/importer/external-api-test.d.ts +18 -0
  28. package/dist/importer/external-api-test.js +50 -0
  29. package/dist/importer/external-backfill-models.d.ts +15 -0
  30. package/dist/importer/external-backfill-models.js +14 -0
  31. package/dist/importer/import-path-maps.d.ts +14 -0
  32. package/dist/importer/import-path-maps.js +49 -0
  33. package/dist/importer/import-progress.d.ts +27 -0
  34. package/dist/importer/import-progress.js +38 -0
  35. package/dist/importer/import-runs.d.ts +43 -0
  36. package/dist/importer/import-runs.js +141 -0
  37. package/dist/importer/import-sessions.js +9 -9
  38. package/dist/importer/importer.d.ts +2 -1
  39. package/dist/importer/importer.js +2 -2
  40. package/dist/importer/map-suggestions.d.ts +39 -0
  41. package/dist/importer/map-suggestions.js +133 -0
  42. package/dist/importer/opencode-import.d.ts +2 -1
  43. package/dist/{adapters/pi/conversation.d.ts → importer/pi-conversation.d.ts} +1 -1
  44. package/dist/importer/profile-import.js +3 -2
  45. package/dist/importer/run-import.d.ts +21 -3
  46. package/dist/importer/run-import.js +82 -2
  47. package/dist/importer/session-loader.d.ts +1 -1
  48. package/dist/importer/web-import-api.d.ts +3 -0
  49. package/dist/importer/web-import-api.js +3 -0
  50. package/dist/importer/web-import-jobs.js +1 -0
  51. package/dist/index.js +9 -4
  52. package/dist/services/ai/live-model-choice.d.ts +4 -1
  53. package/dist/services/ai/live-model-choice.js +18 -0
  54. package/dist/services/api-handlers.d.ts +6 -1
  55. package/dist/services/api-handlers.js +71 -62
  56. package/dist/services/backfill-state.d.ts +30 -0
  57. package/dist/services/backfill-state.js +47 -0
  58. package/dist/services/capture-diagnostics.d.ts +2 -8
  59. package/dist/services/capture-diagnostics.js +3 -49
  60. package/dist/services/global-config-writer.d.ts +2 -0
  61. package/dist/services/global-config-writer.js +37 -6
  62. package/dist/services/global-version.d.ts +31 -0
  63. package/dist/services/global-version.js +78 -0
  64. package/dist/services/memory-key-source.d.ts +35 -0
  65. package/dist/services/memory-key-source.js +123 -0
  66. package/dist/services/package-version.d.ts +2 -0
  67. package/dist/services/package-version.js +21 -0
  68. package/dist/services/private-path.d.ts +13 -0
  69. package/dist/services/private-path.js +54 -0
  70. package/dist/services/settings-snapshot.d.ts +17 -0
  71. package/dist/services/settings-snapshot.js +51 -14
  72. package/dist/services/turso/vector-search.d.ts +1 -1
  73. package/dist/services/turso/vector-search.js +5 -2
  74. package/dist/services/web-server.d.ts +2 -0
  75. package/dist/services/web-server.js +74 -1
  76. package/dist/web/assets/index-DvZhF1BJ.css +2 -0
  77. package/dist/web/assets/index-jqmX5i_i.js +124 -0
  78. package/dist/web/index.html +2 -2
  79. package/package.json +6 -1
  80. package/dist/web/assets/index-B-_gMSn9.js +0 -124
  81. package/dist/web/assets/index-D5ak5KsI.css +0 -2
  82. /package/dist/{adapters/pi/conversation.js → importer/pi-conversation.js} +0 -0
@@ -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
- let timeline = memoriesWithType;
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 promptsWithType = prompts.map((p) => ({
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 = [...memoriesWithType, ...promptsWithType];
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 migrationProgress = {
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
- const projectShards = await tursoShardManager.getAllShards("project", "");
1206
- const allMemories = [];
1207
- for (const shard of projectShards) {
1208
- const db = await tursoConnectionManager.getConnection(shard.dbPath);
1209
- const memories = await db.all("SELECT * FROM memories");
1210
- for (const m of memories) {
1211
- allMemories.push({ memory: m, shard });
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
- if (migrationProgress.total === 0) {
1215
- migrationProgress.total = allMemories.length;
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
- let currentTags = m.tags
1229
- ? m.tags
1230
- .split(",")
1231
- .map((t) => t.trim().toLowerCase())
1232
- .filter((t) => t)
1233
- : [];
1234
- if (currentTags.length === 0) {
1235
- 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.`;
1236
- const result = await provider.executeToolCall("You are a technical tagger.", prompt, {
1237
- type: "function",
1238
- function: {
1239
- name: "save_tags",
1240
- description: "Save generated tags",
1241
- parameters: {
1242
- type: "object",
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
- }, `migration_${m.id}`);
1248
- if (result.success && result.data?.tags) {
1249
- currentTags = result.data.tags;
1250
- await db.run("UPDATE memories SET tags = ? WHERE id = ?", [
1251
- currentTags.join(","),
1252
- m.id,
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 = currentTags.length
1258
- ? await embeddingService.embedWithTimeout(formatTagsForEmbedding(currentTags), {
1259
- task: "document",
1260
- })
1261
- : undefined;
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: m.id, error: errorMsg });
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 function protectTracePath(path: string, mode: number, platform?: NodeJS.Platform, run?: WindowsAclRunner): void;
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 { execFileSync } from "node:child_process";
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 function protectTracePath(path, mode, platform = process.platform, run = execFileSync) {
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.
@@ -1,3 +1,5 @@
1
+ /** The page may point `memoryApiKey` at a source, never store the key itself. */
2
+ export declare function isSecretReference(value: unknown): value is string;
1
3
  export declare class ConfigConflictError extends Error {
2
4
  readonly status = 409;
3
5
  constructor();
@@ -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 === "captureTrace" || key === "autoBackfill" || key === "webServerAutoStart"
54
- ? typeof value !== "boolean"
55
- : key.endsWith("RetentionDays")
56
- ? !Number.isSafeInteger(value) || value < 1
57
- : typeof value !== "string" || !value.trim()) {
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
+ }