om-memory-system 3.2.0-next.9 → 3.3.0-next.18

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 (115) hide show
  1. package/README.md +3 -5
  2. package/dist/adapters/opencode/auto-capture-summary.js +50 -8
  3. package/dist/adapters/opencode/backfill-models.d.ts +25 -0
  4. package/dist/adapters/opencode/backfill-models.js +39 -0
  5. package/dist/adapters/opencode/backfill-startup.d.ts +7 -0
  6. package/dist/adapters/opencode/backfill-startup.js +12 -0
  7. package/dist/adapters/opencode/import-command.d.ts +3 -7
  8. package/dist/adapters/opencode/import-command.js +6 -54
  9. package/dist/adapters/pi/backfill-models.d.ts +9 -0
  10. package/dist/adapters/pi/backfill-models.js +30 -0
  11. package/dist/adapters/pi/extension.js +53 -0
  12. package/dist/adapters/pi/import-command.js +3 -0
  13. package/dist/adapters/pi/profile.js +2 -1
  14. package/dist/adapters/pi/provider.js +41 -14
  15. package/dist/cli/index.js +13 -1
  16. package/dist/cli/web-command.d.ts +3 -0
  17. package/dist/cli/web-command.js +72 -0
  18. package/dist/config.d.ts +30 -1
  19. package/dist/config.js +131 -9
  20. package/dist/core/capture-context.js +1 -1
  21. package/dist/core/capture.d.ts +4 -0
  22. package/dist/core/capture.js +30 -0
  23. package/dist/core/extraction.d.ts +23 -1
  24. package/dist/core/extraction.js +41 -0
  25. package/dist/core/host.d.ts +23 -0
  26. package/dist/core/host.js +9 -1
  27. package/dist/core/profile-analysis.js +9 -1
  28. package/dist/importer/auto-backfill.d.ts +20 -0
  29. package/dist/importer/auto-backfill.js +165 -0
  30. package/dist/importer/backfill-lock.d.ts +3 -0
  31. package/dist/importer/backfill-lock.js +44 -0
  32. package/dist/importer/backfill-model.d.ts +10 -0
  33. package/dist/importer/backfill-model.js +13 -0
  34. package/dist/importer/discovery.d.ts +32 -5
  35. package/dist/importer/discovery.js +88 -36
  36. package/dist/importer/import-args.d.ts +23 -1
  37. package/dist/importer/import-args.js +29 -1
  38. package/dist/importer/import-project.d.ts +16 -0
  39. package/dist/importer/import-project.js +35 -0
  40. package/dist/importer/import-readiness.d.ts +47 -0
  41. package/dist/importer/import-readiness.js +54 -0
  42. package/dist/importer/import-sessions.d.ts +98 -0
  43. package/dist/importer/import-sessions.js +222 -0
  44. package/dist/importer/import-sources.d.ts +47 -0
  45. package/dist/importer/import-sources.js +147 -0
  46. package/dist/importer/importer.d.ts +31 -1
  47. package/dist/importer/importer.js +96 -24
  48. package/dist/importer/manual-import-guard.d.ts +4 -0
  49. package/dist/importer/manual-import-guard.js +9 -0
  50. package/dist/importer/opencode-import.d.ts +11 -1
  51. package/dist/importer/opencode-import.js +133 -69
  52. package/dist/importer/opencode-project.d.ts +1 -4
  53. package/dist/importer/opencode-project.js +3 -36
  54. package/dist/importer/opencode-reader.d.ts +25 -5
  55. package/dist/importer/opencode-reader.js +92 -106
  56. package/dist/importer/opencode-snapshot.d.ts +82 -0
  57. package/dist/importer/opencode-snapshot.js +276 -0
  58. package/dist/importer/profile-import.d.ts +1 -0
  59. package/dist/importer/profile-import.js +7 -1
  60. package/dist/importer/run-import.d.ts +10 -0
  61. package/dist/importer/run-import.js +21 -0
  62. package/dist/importer/settings-health.d.ts +24 -0
  63. package/dist/importer/settings-health.js +127 -0
  64. package/dist/importer/web-import-api.d.ts +8 -0
  65. package/dist/importer/web-import-api.js +8 -0
  66. package/dist/importer/web-import-jobs.d.ts +53 -0
  67. package/dist/importer/web-import-jobs.js +227 -0
  68. package/dist/index.js +34 -0
  69. package/dist/services/ai/live-model-choice.js +6 -3
  70. package/dist/services/ai/opencode-import-models.d.ts +11 -0
  71. package/dist/services/ai/opencode-import-models.js +55 -0
  72. package/dist/services/ai/opencode-provider.d.ts +9 -0
  73. package/dist/services/ai/opencode-provider.js +31 -4
  74. package/dist/services/ai/providers/anthropic-messages.js +14 -6
  75. package/dist/services/ai/providers/base-provider.d.ts +8 -0
  76. package/dist/services/ai/providers/base-provider.js +12 -0
  77. package/dist/services/ai/providers/google-gemini.js +21 -7
  78. package/dist/services/ai/providers/openai-chat-completion.js +20 -7
  79. package/dist/services/ai/providers/openai-responses.js +11 -0
  80. package/dist/services/backfill-state.d.ts +25 -0
  81. package/dist/services/backfill-state.js +68 -0
  82. package/dist/services/capture-attempt-store.d.ts +21 -0
  83. package/dist/services/capture-attempt-store.js +108 -0
  84. package/dist/services/capture-diagnostics.d.ts +66 -0
  85. package/dist/services/capture-diagnostics.js +175 -0
  86. package/dist/services/cleanup-service.js +9 -0
  87. package/dist/services/global-config-writer.d.ts +10 -0
  88. package/dist/services/global-config-writer.js +110 -0
  89. package/dist/services/live-captured-entries.d.ts +3 -0
  90. package/dist/services/live-captured-entries.js +34 -0
  91. package/dist/services/log-path.d.ts +2 -0
  92. package/dist/services/log-path.js +15 -0
  93. package/dist/services/logger.js +1 -12
  94. package/dist/services/safe-health-error.d.ts +2 -0
  95. package/dist/services/safe-health-error.js +11 -0
  96. package/dist/services/settings-log.d.ts +5 -0
  97. package/dist/services/settings-log.js +36 -0
  98. package/dist/services/settings-models.d.ts +35 -0
  99. package/dist/services/settings-models.js +39 -0
  100. package/dist/services/settings-snapshot.d.ts +24 -0
  101. package/dist/services/settings-snapshot.js +83 -0
  102. package/dist/services/settings-traces.d.ts +7 -0
  103. package/dist/services/settings-traces.js +39 -0
  104. package/dist/services/user-memory-learning.js +2 -1
  105. package/dist/services/web-autostart.d.ts +29 -0
  106. package/dist/services/web-autostart.js +208 -0
  107. package/dist/services/web-server.d.ts +5 -0
  108. package/dist/services/web-server.js +187 -3
  109. package/dist/v2/legacy-client.js +19 -2
  110. package/dist/web/assets/index-B-_gMSn9.js +124 -0
  111. package/dist/web/assets/index-D5ak5KsI.css +2 -0
  112. package/dist/web/index.html +2 -2
  113. package/package.json +2 -1
  114. package/dist/web/assets/index-DPjEpD1h.css +0 -2
  115. package/dist/web/assets/index-do26c4JJ.js +0 -122
@@ -0,0 +1,108 @@
1
+ import { join } from "node:path";
2
+ import { CONFIG } from "../config.js";
3
+ import { tursoConnectionManager } from "./turso/connection-manager.js";
4
+ const prepared = new Set();
5
+ // Keyed by path: a store change mid-preparation must prepare the new store too.
6
+ const preparing = new Map();
7
+ async function database() {
8
+ const path = join(CONFIG.storagePath, "user-prompts.db");
9
+ const db = await tursoConnectionManager.getConnection(path);
10
+ if (!prepared.has(path)) {
11
+ let pending = preparing.get(path);
12
+ if (!pending) {
13
+ pending = db
14
+ .batch([
15
+ {
16
+ sql: `CREATE TABLE IF NOT EXISTS capture_attempts (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ created_at INTEGER NOT NULL,
19
+ host TEXT NOT NULL, source_type TEXT NOT NULL, session_id TEXT NOT NULL,
20
+ path TEXT, provider TEXT, model TEXT, stop_reason TEXT, block_types TEXT,
21
+ prompt_chars INTEGER, reply_chars INTEGER, duration_ms INTEGER NOT NULL,
22
+ outcome TEXT NOT NULL, reason TEXT
23
+ )`,
24
+ },
25
+ {
26
+ sql: "CREATE INDEX IF NOT EXISTS idx_capture_attempts_time ON capture_attempts(created_at)",
27
+ },
28
+ {
29
+ sql: "CREATE INDEX IF NOT EXISTS idx_capture_attempts_host_model ON capture_attempts(host, provider, model, created_at)",
30
+ },
31
+ ])
32
+ .then(() => {
33
+ prepared.add(path);
34
+ })
35
+ .finally(() => {
36
+ preparing.delete(path);
37
+ });
38
+ preparing.set(path, pending);
39
+ }
40
+ await pending;
41
+ }
42
+ return db;
43
+ }
44
+ export async function saveCaptureAttempt(record, now = Date.now()) {
45
+ const db = await database();
46
+ await db.run(`INSERT INTO capture_attempts
47
+ (created_at, host, source_type, session_id, path, provider, model, stop_reason,
48
+ block_types, prompt_chars, reply_chars, duration_ms, outcome, reason)
49
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
50
+ now,
51
+ record.host,
52
+ record.sourceType,
53
+ record.sessionId,
54
+ record.path ?? null,
55
+ record.provider ?? null,
56
+ record.model ?? null,
57
+ record.stopReason ?? null,
58
+ record.blockTypes ? JSON.stringify(record.blockTypes) : null,
59
+ record.promptChars ?? null,
60
+ record.replyChars ?? null,
61
+ record.durationMs,
62
+ record.outcome,
63
+ record.reason ?? null,
64
+ ]);
65
+ }
66
+ export async function pruneCaptureAttempts(days, now = Date.now()) {
67
+ const db = await database();
68
+ return db.run("DELETE FROM capture_attempts WHERE created_at < ?", [now - days * 86400000]);
69
+ }
70
+ export async function queryCaptureAttempts(from, to, limit = 100) {
71
+ const db = await database();
72
+ const range = [from, to];
73
+ const byModel = await db.all(`SELECT host, provider, model, COUNT(*) AS total,
74
+ SUM(CASE WHEN outcome = 'saved' THEN 1 ELSE 0 END) AS saved,
75
+ SUM(CASE WHEN outcome = 'skipped' THEN 1 ELSE 0 END) AS skipped,
76
+ SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS failed
77
+ FROM capture_attempts WHERE created_at BETWEEN ? AND ?
78
+ GROUP BY host, provider, model ORDER BY total DESC`, range);
79
+ const byReason = await db.all(`SELECT host, provider, model, reason, COUNT(*) AS count
80
+ FROM capture_attempts WHERE created_at BETWEEN ? AND ? AND outcome = 'failed'
81
+ GROUP BY host, provider, model, reason ORDER BY count DESC`, range);
82
+ const recent = await db.all(`SELECT created_at AS timestamp, host,
83
+ source_type AS sourceType, session_id AS sessionId, path, provider, model,
84
+ stop_reason AS stopReason, block_types AS blockTypes, prompt_chars AS promptChars,
85
+ reply_chars AS replyChars, duration_ms AS durationMs, outcome, reason
86
+ FROM capture_attempts WHERE created_at BETWEEN ? AND ?
87
+ ORDER BY created_at DESC, id DESC LIMIT ?`, [...range, Math.max(1, Math.min(500, limit))]);
88
+ return {
89
+ byModel: byModel.map((row) => ({
90
+ ...row,
91
+ total: Number(row.total),
92
+ saved: Number(row.saved),
93
+ skipped: Number(row.skipped),
94
+ failed: Number(row.failed),
95
+ })),
96
+ byReason: byReason.map((row) => ({
97
+ host: String(row.host),
98
+ provider: row.provider,
99
+ model: row.model,
100
+ reason: String(row.reason),
101
+ count: Number(row.count),
102
+ })),
103
+ recent: recent.map((row) => ({
104
+ ...row,
105
+ blockTypes: row.blockTypes ? JSON.parse(String(row.blockTypes)) : null,
106
+ })),
107
+ };
108
+ }
@@ -0,0 +1,66 @@
1
+ import type { CaptureAttemptDiagnostics, CaptureAttemptOutcome, CaptureExtractionPath, CaptureFailureReason } from "../core/host.js";
2
+ import type { MemoryHost, MemorySourceType } from "../types/index.js";
3
+ /**
4
+ * The config fields this module reads. Callers pass `CONFIG`; tests pass a
5
+ * plain object, so no import of `../config.js` is needed here.
6
+ */
7
+ export interface CaptureDiagnosticsConfig {
8
+ captureTrace?: boolean;
9
+ captureTraceRetentionDays?: number;
10
+ memoryApiKey?: string;
11
+ embeddingApiKey?: string;
12
+ webServerApiToken?: string;
13
+ webServerAuthPassword?: string;
14
+ }
15
+ export interface CaptureAttemptContext {
16
+ host: MemoryHost;
17
+ sourceType: MemorySourceType;
18
+ sessionId: string;
19
+ }
20
+ /**
21
+ * The metadata-only record written to the OMMS log for every attempt. Every
22
+ * key is always present so records from both hosts have one shape; a value a
23
+ * path could not observe is null.
24
+ */
25
+ export interface CaptureAttemptRecord {
26
+ host: MemoryHost;
27
+ sourceType: MemorySourceType;
28
+ sessionId: string;
29
+ path: CaptureExtractionPath | null;
30
+ provider: string | null;
31
+ model: string | null;
32
+ stopReason: string | null;
33
+ blockTypes: string[] | null;
34
+ promptChars: number | null;
35
+ replyChars: number | null;
36
+ durationMs: number;
37
+ outcome: CaptureAttemptOutcome;
38
+ reason: CaptureFailureReason | null;
39
+ }
40
+ export declare const DEFAULT_CAPTURE_TRACE_RETENTION_DAYS = 7;
41
+ export declare function buildCaptureAttemptRecord(context: CaptureAttemptContext, diagnostics: CaptureAttemptDiagnostics, outcome: CaptureAttemptOutcome, durationMs: number): CaptureAttemptRecord;
42
+ /**
43
+ * Prepare prompt or reply text for the trace file: drop `<private>` regions the
44
+ * same way memory storage does, then configured secrets, then common key and
45
+ * token formats.
46
+ */
47
+ export declare function redactTraceText(text: string, config: CaptureDiagnosticsConfig): string;
48
+ export declare function getTraceDirectory(): string;
49
+ /**
50
+ * Delete trace files dated more than `captureTraceRetentionDays` before `now`.
51
+ * The date comes from the file name, not the modification time, and files
52
+ * that do not match the trace name pattern are never touched.
53
+ */
54
+ export declare function pruneTraces(config: CaptureDiagnosticsConfig, now?: Date): number;
55
+ /**
56
+ * Append one trace entry to the day's JSON Lines file. A single append per
57
+ * entry keeps lines whole when Pi and OpenCode write at the same time.
58
+ */
59
+ export declare function writeTraceEntry(record: CaptureAttemptRecord, diagnostics: CaptureAttemptDiagnostics, config: CaptureDiagnosticsConfig, now?: Date): void;
60
+ /**
61
+ * Write the attempt's metadata record to the log and, when tracing is on, its
62
+ * trace entry. Never throws: diagnostics must not change a capture outcome.
63
+ */
64
+ export declare function emitCaptureAttempt(record: CaptureAttemptRecord, diagnostics: CaptureAttemptDiagnostics, config: CaptureDiagnosticsConfig, now?: Date): void;
65
+ /** Test hook: forget which day the last trace was written. */
66
+ export declare function resetTraceStateForTests(): void;
@@ -0,0 +1,175 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { getLogDirPath } from "./log-path.js";
4
+ import { log } from "./logger.js";
5
+ import { stripPrivateContent } from "./privacy.js";
6
+ export const DEFAULT_CAPTURE_TRACE_RETENTION_DAYS = 7;
7
+ const TRACE_SCHEMA_VERSION = 1;
8
+ const TRACE_FILE = /^capture-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
9
+ const REDACTED = "[REDACTED]";
10
+ export function buildCaptureAttemptRecord(context, diagnostics, outcome, durationMs) {
11
+ const promptChars = diagnostics.systemPrompt === undefined && diagnostics.userPrompt === undefined
12
+ ? null
13
+ : (diagnostics.systemPrompt?.length ?? 0) + (diagnostics.userPrompt?.length ?? 0);
14
+ return {
15
+ host: context.host,
16
+ sourceType: context.sourceType,
17
+ sessionId: context.sessionId,
18
+ path: diagnostics.path ?? null,
19
+ provider: diagnostics.provider ?? null,
20
+ model: diagnostics.model ?? null,
21
+ stopReason: diagnostics.stopReason ?? null,
22
+ blockTypes: diagnostics.blockTypes ?? null,
23
+ promptChars,
24
+ replyChars: diagnostics.rawReply?.length ?? null,
25
+ durationMs,
26
+ outcome,
27
+ reason: outcome === "failed" ? (diagnostics.failureReason ?? "call-error") : null,
28
+ };
29
+ }
30
+ // Common key and token formats. Each match is replaced whole, except the
31
+ // Bearer rule, which keeps the scheme so the trace still reads naturally.
32
+ const SECRET_PATTERNS = [
33
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
34
+ [/\bsk-ant-[A-Za-z0-9_-]{10,}/g, REDACTED],
35
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, REDACTED],
36
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, REDACTED],
37
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, REDACTED],
38
+ [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
39
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, REDACTED],
40
+ [/\bAIza[0-9A-Za-z_-]{35}/g, REDACTED],
41
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, REDACTED],
42
+ [/(\bBearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi, `$1${REDACTED}`],
43
+ ];
44
+ function configuredSecrets(config) {
45
+ return [
46
+ config.memoryApiKey,
47
+ config.embeddingApiKey,
48
+ config.webServerApiToken,
49
+ config.webServerAuthPassword,
50
+ ].filter((value) => typeof value === "string" &&
51
+ // Short values would redact ordinary words; unresolved references are not secrets.
52
+ value.length >= 8 &&
53
+ !value.startsWith("env://") &&
54
+ !value.startsWith("file://"));
55
+ }
56
+ /**
57
+ * Prepare prompt or reply text for the trace file: drop `<private>` regions the
58
+ * same way memory storage does, then configured secrets, then common key and
59
+ * token formats.
60
+ */
61
+ export function redactTraceText(text, config) {
62
+ let result = stripPrivateContent(text);
63
+ for (const secret of configuredSecrets(config)) {
64
+ result = result.replaceAll(secret, REDACTED);
65
+ }
66
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
67
+ result = result.replace(pattern, replacement);
68
+ }
69
+ return result;
70
+ }
71
+ function formatDate(d) {
72
+ const pad = (n) => String(n).padStart(2, "0");
73
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
74
+ }
75
+ export function getTraceDirectory() {
76
+ return join(getLogDirPath(), "traces");
77
+ }
78
+ function retentionDays(config) {
79
+ const days = config.captureTraceRetentionDays;
80
+ if (typeof days !== "number" || !Number.isFinite(days)) {
81
+ return DEFAULT_CAPTURE_TRACE_RETENTION_DAYS;
82
+ }
83
+ return Math.max(1, Math.floor(days));
84
+ }
85
+ /**
86
+ * Delete trace files dated more than `captureTraceRetentionDays` before `now`.
87
+ * The date comes from the file name, not the modification time, and files
88
+ * that do not match the trace name pattern are never touched.
89
+ */
90
+ export function pruneTraces(config, now = new Date()) {
91
+ const dir = getTraceDirectory();
92
+ let removed = 0;
93
+ try {
94
+ if (!existsSync(dir))
95
+ return 0;
96
+ const cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
97
+ cutoff.setDate(cutoff.getDate() - retentionDays(config));
98
+ for (const file of readdirSync(dir)) {
99
+ const match = TRACE_FILE.exec(file);
100
+ if (!match)
101
+ continue;
102
+ const fileDate = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
103
+ if (fileDate < cutoff) {
104
+ unlinkSync(join(dir, file));
105
+ removed++;
106
+ }
107
+ }
108
+ }
109
+ catch (error) {
110
+ log("Capture trace cleanup failed", { code: errorCode(error) });
111
+ }
112
+ return removed;
113
+ }
114
+ let lastTraceDate = null;
115
+ /**
116
+ * Append one trace entry to the day's JSON Lines file. A single append per
117
+ * entry keeps lines whole when Pi and OpenCode write at the same time.
118
+ */
119
+ export function writeTraceEntry(record, diagnostics, config, now = new Date()) {
120
+ try {
121
+ const today = formatDate(now);
122
+ if (lastTraceDate !== today) {
123
+ lastTraceDate = today;
124
+ pruneTraces(config, now);
125
+ }
126
+ const dir = getTraceDirectory();
127
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
128
+ chmodSync(dir, 0o700);
129
+ const redact = (value) => value === undefined ? null : redactTraceText(value, config);
130
+ const entry = {
131
+ schemaVersion: TRACE_SCHEMA_VERSION,
132
+ timestamp: now.toISOString(),
133
+ ...record,
134
+ systemPrompt: redact(diagnostics.systemPrompt),
135
+ userPrompt: redact(diagnostics.userPrompt),
136
+ reply: redact(diagnostics.rawReply),
137
+ };
138
+ const file = join(dir, `capture-${today}.jsonl`);
139
+ appendFileSync(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
140
+ // The mode above applies only when the append creates the file; a file
141
+ // that already existed with wider permissions is narrowed here.
142
+ chmodSync(file, 0o600);
143
+ }
144
+ catch (error) {
145
+ log("Capture trace write failed", { code: errorCode(error) });
146
+ }
147
+ }
148
+ /**
149
+ * Write the attempt's metadata record to the log and, when tracing is on, its
150
+ * trace entry. Never throws: diagnostics must not change a capture outcome.
151
+ */
152
+ export function emitCaptureAttempt(record, diagnostics, config, now = new Date()) {
153
+ try {
154
+ log("Capture attempt", record);
155
+ }
156
+ catch {
157
+ // The log is best effort here, as everywhere else.
158
+ }
159
+ void import("./capture-attempt-store.js")
160
+ .then(({ saveCaptureAttempt }) => saveCaptureAttempt(record, now.getTime()))
161
+ .catch((error) => log("Capture attempt store failed", { code: errorCode(error) }));
162
+ if (config.captureTrace === true) {
163
+ writeTraceEntry(record, diagnostics, config, now);
164
+ }
165
+ }
166
+ function errorCode(error) {
167
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
168
+ return error.code;
169
+ }
170
+ return error instanceof Error ? error.name : "unknown";
171
+ }
172
+ /** Test hook: forget which day the last trace was written. */
173
+ export function resetTraceStateForTests() {
174
+ lastTraceDate = null;
175
+ }
@@ -77,6 +77,15 @@ export class CleanupService {
77
77
  }
78
78
  }
79
79
  const promptsDeleted = promptCleanupResult.deleted - linkedMemoryIds.size;
80
+ try {
81
+ const { pruneCaptureAttempts } = await import("./capture-attempt-store.js");
82
+ await pruneCaptureAttempts(CONFIG.captureAttemptRetentionDays);
83
+ }
84
+ catch (error) {
85
+ log("Capture attempt cleanup failed", {
86
+ code: error instanceof Error ? error.name : "unknown",
87
+ });
88
+ }
80
89
  try {
81
90
  await userPromptManager.vacuum();
82
91
  log("Cleanup: VACUUM done", { db: "user-prompts.db" });
@@ -0,0 +1,10 @@
1
+ export declare class ConfigConflictError extends Error {
2
+ readonly status = 409;
3
+ constructor();
4
+ }
5
+ export declare function readGlobalConfigRevision(): string;
6
+ /** Write only page-editable keys; reject edits based on a stale config revision. */
7
+ export declare function writeGlobalConfigKeys(edits: Record<string, unknown>, expectedRevision: string): Promise<{
8
+ revision: string;
9
+ migratedLegacy: boolean;
10
+ }>;
@@ -0,0 +1,110 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { promises as fs, readFileSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { applyEdits, modify, parse } from "jsonc-parser";
5
+ import { CONFIG_TEMPLATE, getGlobalConfigSourcePath, getGlobalConfigWritePath, validateGlobalConfig, } from "../config.js";
6
+ const keys = new Set([
7
+ "opencodeProvider",
8
+ "opencodeModel",
9
+ "piProvider",
10
+ "piModel",
11
+ "captureTrace",
12
+ "captureTraceRetentionDays",
13
+ "captureAttemptRetentionDays",
14
+ "autoBackfill",
15
+ "opencodeBackfillModel",
16
+ "piBackfillModel",
17
+ "webServerAutoStart",
18
+ ]);
19
+ export class ConfigConflictError extends Error {
20
+ status = 409;
21
+ constructor() {
22
+ super("Config changed. Reload settings and save again.");
23
+ }
24
+ }
25
+ function digest(content) {
26
+ return createHash("sha256").update(content).digest("hex");
27
+ }
28
+ async function snapshot(path) {
29
+ const content = await fs.readFile(path, "utf8");
30
+ const stat = await fs.stat(path);
31
+ return { content, revision: `${stat.mtimeMs}:${stat.size}:${digest(content)}` };
32
+ }
33
+ export function readGlobalConfigRevision() {
34
+ const source = getGlobalConfigSourcePath();
35
+ if (!source)
36
+ return digest(CONFIG_TEMPLATE);
37
+ // Synchronous read keeps the revision tied to the file shown by GET /settings.
38
+ const content = readFileSync(source, "utf8");
39
+ const stat = statSync(source);
40
+ return `${stat.mtimeMs}:${stat.size}:${digest(content)}`;
41
+ }
42
+ let queue = Promise.resolve();
43
+ let pending = 0;
44
+ let lastBaseRevision;
45
+ let lastWrittenRevision;
46
+ /** Write only page-editable keys; reject edits based on a stale config revision. */
47
+ export async function writeGlobalConfigKeys(edits, expectedRevision) {
48
+ if (!edits || !Object.keys(edits).length)
49
+ throw new Error("No settings to save");
50
+ for (const [key, value] of Object.entries(edits)) {
51
+ if (!keys.has(key))
52
+ 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`);
59
+ }
60
+ }
61
+ const wasQueued = pending > 0;
62
+ pending++;
63
+ const save = queue.then(async () => {
64
+ const source = getGlobalConfigSourcePath();
65
+ const target = getGlobalConfigWritePath();
66
+ const original = source
67
+ ? await snapshot(source)
68
+ : { content: CONFIG_TEMPLATE, revision: digest(CONFIG_TEMPLATE) };
69
+ if (original.revision !== expectedRevision &&
70
+ !(wasQueued &&
71
+ lastBaseRevision === expectedRevision &&
72
+ lastWrittenRevision === original.revision)) {
73
+ throw new ConfigConflictError();
74
+ }
75
+ let content = original.content;
76
+ for (const [key, value] of Object.entries(edits)) {
77
+ content = applyEdits(content, modify(content, [key], value, {
78
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
79
+ }));
80
+ }
81
+ const errors = [];
82
+ const parsed = parse(content, errors, { allowTrailingComma: true });
83
+ if (errors.length)
84
+ throw new Error("Config contains invalid JSONC");
85
+ validateGlobalConfig(parsed);
86
+ if (source && (await snapshot(source)).revision !== original.revision) {
87
+ throw new ConfigConflictError();
88
+ }
89
+ await fs.mkdir(dirname(target), { recursive: true });
90
+ const temp = join(dirname(target), `.omms-${randomUUID()}.tmp`);
91
+ try {
92
+ await fs.writeFile(temp, content, { mode: source ? (await fs.stat(source)).mode : 0o600 });
93
+ await fs.rename(temp, target);
94
+ }
95
+ finally {
96
+ await fs.rm(temp, { force: true });
97
+ }
98
+ const revision = (await snapshot(target)).revision;
99
+ lastBaseRevision = expectedRevision;
100
+ lastWrittenRevision = revision;
101
+ return { revision, migratedLegacy: Boolean(source && source !== target) };
102
+ });
103
+ queue = save.then(() => { }, () => { });
104
+ try {
105
+ return await save;
106
+ }
107
+ finally {
108
+ pending--;
109
+ }
110
+ }
@@ -0,0 +1,3 @@
1
+ import type { MemoryHost } from "../types/index.js";
2
+ /** Read user and assistant IDs saved by live capture in one project session. */
3
+ export declare function findLiveCapturedEntryIds(projectHash: string, host: MemoryHost, sessionId: string): Promise<Set<string>>;
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG } from "../config.js";
4
+ import { tursoConnectionManager } from "./turso/connection-manager.js";
5
+ import { tursoShardManager } from "./turso/shard-manager.js";
6
+ /** Read user and assistant IDs saved by live capture in one project session. */
7
+ export async function findLiveCapturedEntryIds(projectHash, host, sessionId) {
8
+ const entries = new Set();
9
+ if (!existsSync(join(CONFIG.storagePath, "metadata.db")))
10
+ return entries;
11
+ const shards = await tursoShardManager.getAllShards("project", projectHash);
12
+ for (const shard of shards) {
13
+ const db = await tursoConnectionManager.getConnection(shard.dbPath);
14
+ const rows = await db.all(`SELECT metadata FROM memories WHERE json_valid(metadata)
15
+ AND json_extract(metadata, '$.host') = ?
16
+ AND json_extract(metadata, '$.hostSessionId') = ?
17
+ AND json_extract(metadata, '$.sourceType') != 'history-import'`, [host, sessionId]);
18
+ for (const row of rows) {
19
+ const metadata = JSON.parse(row.metadata);
20
+ if (!metadata || typeof metadata !== "object")
21
+ continue;
22
+ const provenance = metadata;
23
+ if (typeof provenance.promptId === "string")
24
+ entries.add(provenance.promptId);
25
+ const ids = provenance.sourceEntryIds;
26
+ if (Array.isArray(ids)) {
27
+ for (const id of ids)
28
+ if (typeof id === "string")
29
+ entries.add(id);
30
+ }
31
+ }
32
+ }
33
+ return entries;
34
+ }
@@ -0,0 +1,2 @@
1
+ export declare function getLogFilePath(): string;
2
+ export declare function getLogDirPath(): string;
@@ -0,0 +1,15 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ // Kept apart from logger.ts: many tests replace logger.js with a stub that
4
+ // only has `log`, so modules that need the paths import them from here.
5
+ export function getLogFilePath() {
6
+ return (process.env.OMMS_LOG_FILE ||
7
+ // Legacy override, still honoured from the opencode-mem days.
8
+ process.env.OPENCODE_MEM_LOG_FILE ||
9
+ join(homedir(), ".omms", "omms.log"));
10
+ }
11
+ export function getLogDirPath() {
12
+ const logFile = getLogFilePath();
13
+ const lastSlash = Math.max(logFile.lastIndexOf("/"), logFile.lastIndexOf("\\"));
14
+ return lastSlash === -1 ? "." : logFile.slice(0, lastSlash);
15
+ }
@@ -1,17 +1,6 @@
1
1
  import { appendFileSync, writeFileSync, existsSync, mkdirSync, statSync, renameSync, unlinkSync, readdirSync, } from "fs";
2
- import { homedir } from "os";
3
2
  import { join } from "path";
4
- function getLogFilePath() {
5
- return (process.env.OMMS_LOG_FILE ||
6
- // Legacy override, still honoured from the opencode-mem days.
7
- process.env.OPENCODE_MEM_LOG_FILE ||
8
- join(homedir(), ".omms", "omms.log"));
9
- }
10
- function getLogDirPath() {
11
- const logFile = getLogFilePath();
12
- const lastSlash = Math.max(logFile.lastIndexOf("/"), logFile.lastIndexOf("\\"));
13
- return lastSlash === -1 ? "." : logFile.slice(0, lastSlash);
14
- }
3
+ import { getLogDirPath, getLogFilePath } from "./log-path.js";
15
4
  const MAX_LOG_SIZE = 5 * 1024 * 1024;
16
5
  const MAX_LOG_DAYS = 30;
17
6
  const GLOBAL_LOGGER_KEY = Symbol.for("omms.logger.initialized");
@@ -0,0 +1,2 @@
1
+ /** Remove configured credentials before returning a model error to the browser. */
2
+ export declare function safeHealthError(error: unknown, secrets: Array<string | undefined>): string;
@@ -0,0 +1,11 @@
1
+ /** Remove configured credentials before returning a model error to the browser. */
2
+ export function safeHealthError(error, secrets) {
3
+ let message = error instanceof Error ? error.message : String(error);
4
+ for (const secret of secrets) {
5
+ if (secret && secret.length > 2)
6
+ message = message.replaceAll(secret, "[redacted]");
7
+ }
8
+ return message
9
+ .replace(/(Bearer\s+)[^\s]+/gi, "$1[redacted]")
10
+ .replace(/(api[_-]?key[=:]\s*)[^\s&]+/gi, "$1[redacted]");
11
+ }
@@ -0,0 +1,5 @@
1
+ /** Read the bounded tail of OMMS's own log. The request cannot choose a path. */
2
+ export declare function readSettingsLog(lines?: number, captureOnly?: boolean): Promise<{
3
+ path: string;
4
+ lines: string[];
5
+ }>;
@@ -0,0 +1,36 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import { getLogFilePath } from "./log-path.js";
3
+ /** Read the bounded tail of OMMS's own log. The request cannot choose a path. */
4
+ export async function readSettingsLog(lines = 200, captureOnly = false) {
5
+ const path = getLogFilePath();
6
+ const count = Number.isFinite(lines) ? Math.max(1, Math.min(2000, Math.floor(lines))) : 200;
7
+ let size;
8
+ try {
9
+ size = (await stat(path)).size;
10
+ }
11
+ catch (error) {
12
+ if (error.code === "ENOENT")
13
+ return { path, lines: [] };
14
+ throw error;
15
+ }
16
+ const start = Math.max(0, size - 256 * 1024);
17
+ const file = await open(path, "r");
18
+ try {
19
+ const buffer = Buffer.alloc(size - start);
20
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
21
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
22
+ const whole = start === 0 ? text : text.slice(text.indexOf("\n") + 1);
23
+ if (start > 0 && !text.includes("\n"))
24
+ return { path, lines: [] };
25
+ const rows = whole.split(/\r?\n/);
26
+ if (rows.at(-1) === "")
27
+ rows.pop();
28
+ return {
29
+ path,
30
+ lines: rows.filter((line) => !captureOnly || line.includes("Capture attempt")).slice(-count),
31
+ };
32
+ }
33
+ finally {
34
+ await file.close();
35
+ }
36
+ }
@@ -0,0 +1,35 @@
1
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
2
+ export declare function listOpencodeSettingsModels(client?: Pick<OpencodeClient, "provider">): Promise<{
3
+ available: false;
4
+ reason: string;
5
+ models?: undefined;
6
+ } | {
7
+ available: true;
8
+ models: {
9
+ provider: string;
10
+ model: string;
11
+ name: string;
12
+ }[];
13
+ reason?: undefined;
14
+ }>;
15
+ export declare function listPiSettingsModels(loadSdk?: () => Promise<typeof import("@earendil-works/pi-coding-agent")>): Promise<{
16
+ available: true;
17
+ models: {
18
+ provider: string;
19
+ model: string;
20
+ name: string;
21
+ }[];
22
+ reason?: undefined;
23
+ } | {
24
+ available: false;
25
+ models: {
26
+ provider: string;
27
+ model: string;
28
+ name: string;
29
+ }[];
30
+ reason: string;
31
+ } | {
32
+ available: false;
33
+ reason: string;
34
+ models?: undefined;
35
+ }>;