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.
- package/README.md +3 -5
- package/dist/adapters/opencode/auto-capture-summary.js +50 -8
- package/dist/adapters/opencode/backfill-models.d.ts +25 -0
- package/dist/adapters/opencode/backfill-models.js +39 -0
- package/dist/adapters/opencode/backfill-startup.d.ts +7 -0
- package/dist/adapters/opencode/backfill-startup.js +12 -0
- package/dist/adapters/opencode/import-command.d.ts +3 -7
- package/dist/adapters/opencode/import-command.js +6 -54
- package/dist/adapters/pi/backfill-models.d.ts +9 -0
- package/dist/adapters/pi/backfill-models.js +30 -0
- package/dist/adapters/pi/extension.js +53 -0
- package/dist/adapters/pi/import-command.js +3 -0
- package/dist/adapters/pi/profile.js +2 -1
- package/dist/adapters/pi/provider.js +41 -14
- package/dist/cli/index.js +13 -1
- package/dist/cli/web-command.d.ts +3 -0
- package/dist/cli/web-command.js +72 -0
- package/dist/config.d.ts +30 -1
- package/dist/config.js +131 -9
- package/dist/core/capture-context.js +1 -1
- package/dist/core/capture.d.ts +4 -0
- package/dist/core/capture.js +30 -0
- package/dist/core/extraction.d.ts +23 -1
- package/dist/core/extraction.js +41 -0
- package/dist/core/host.d.ts +23 -0
- package/dist/core/host.js +9 -1
- package/dist/core/profile-analysis.js +9 -1
- package/dist/importer/auto-backfill.d.ts +20 -0
- package/dist/importer/auto-backfill.js +165 -0
- package/dist/importer/backfill-lock.d.ts +3 -0
- package/dist/importer/backfill-lock.js +44 -0
- package/dist/importer/backfill-model.d.ts +10 -0
- package/dist/importer/backfill-model.js +13 -0
- package/dist/importer/discovery.d.ts +32 -5
- package/dist/importer/discovery.js +88 -36
- package/dist/importer/import-args.d.ts +23 -1
- package/dist/importer/import-args.js +29 -1
- package/dist/importer/import-project.d.ts +16 -0
- package/dist/importer/import-project.js +35 -0
- package/dist/importer/import-readiness.d.ts +47 -0
- package/dist/importer/import-readiness.js +54 -0
- package/dist/importer/import-sessions.d.ts +98 -0
- package/dist/importer/import-sessions.js +222 -0
- package/dist/importer/import-sources.d.ts +47 -0
- package/dist/importer/import-sources.js +147 -0
- package/dist/importer/importer.d.ts +31 -1
- package/dist/importer/importer.js +96 -24
- package/dist/importer/manual-import-guard.d.ts +4 -0
- package/dist/importer/manual-import-guard.js +9 -0
- package/dist/importer/opencode-import.d.ts +11 -1
- package/dist/importer/opencode-import.js +133 -69
- package/dist/importer/opencode-project.d.ts +1 -4
- package/dist/importer/opencode-project.js +3 -36
- package/dist/importer/opencode-reader.d.ts +25 -5
- package/dist/importer/opencode-reader.js +92 -106
- package/dist/importer/opencode-snapshot.d.ts +82 -0
- package/dist/importer/opencode-snapshot.js +276 -0
- package/dist/importer/profile-import.d.ts +1 -0
- package/dist/importer/profile-import.js +7 -1
- package/dist/importer/run-import.d.ts +10 -0
- package/dist/importer/run-import.js +21 -0
- package/dist/importer/settings-health.d.ts +24 -0
- package/dist/importer/settings-health.js +127 -0
- package/dist/importer/web-import-api.d.ts +8 -0
- package/dist/importer/web-import-api.js +8 -0
- package/dist/importer/web-import-jobs.d.ts +53 -0
- package/dist/importer/web-import-jobs.js +227 -0
- package/dist/index.js +34 -0
- package/dist/services/ai/live-model-choice.js +6 -3
- package/dist/services/ai/opencode-import-models.d.ts +11 -0
- package/dist/services/ai/opencode-import-models.js +55 -0
- package/dist/services/ai/opencode-provider.d.ts +9 -0
- package/dist/services/ai/opencode-provider.js +31 -4
- package/dist/services/ai/providers/anthropic-messages.js +14 -6
- package/dist/services/ai/providers/base-provider.d.ts +8 -0
- package/dist/services/ai/providers/base-provider.js +12 -0
- package/dist/services/ai/providers/google-gemini.js +21 -7
- package/dist/services/ai/providers/openai-chat-completion.js +20 -7
- package/dist/services/ai/providers/openai-responses.js +11 -0
- package/dist/services/backfill-state.d.ts +25 -0
- package/dist/services/backfill-state.js +68 -0
- package/dist/services/capture-attempt-store.d.ts +21 -0
- package/dist/services/capture-attempt-store.js +108 -0
- package/dist/services/capture-diagnostics.d.ts +66 -0
- package/dist/services/capture-diagnostics.js +175 -0
- package/dist/services/cleanup-service.js +9 -0
- package/dist/services/global-config-writer.d.ts +10 -0
- package/dist/services/global-config-writer.js +110 -0
- package/dist/services/live-captured-entries.d.ts +3 -0
- package/dist/services/live-captured-entries.js +34 -0
- package/dist/services/log-path.d.ts +2 -0
- package/dist/services/log-path.js +15 -0
- package/dist/services/logger.js +1 -12
- package/dist/services/safe-health-error.d.ts +2 -0
- package/dist/services/safe-health-error.js +11 -0
- package/dist/services/settings-log.d.ts +5 -0
- package/dist/services/settings-log.js +36 -0
- package/dist/services/settings-models.d.ts +35 -0
- package/dist/services/settings-models.js +39 -0
- package/dist/services/settings-snapshot.d.ts +24 -0
- package/dist/services/settings-snapshot.js +83 -0
- package/dist/services/settings-traces.d.ts +7 -0
- package/dist/services/settings-traces.js +39 -0
- package/dist/services/user-memory-learning.js +2 -1
- package/dist/services/web-autostart.d.ts +29 -0
- package/dist/services/web-autostart.js +208 -0
- package/dist/services/web-server.d.ts +5 -0
- package/dist/services/web-server.js +187 -3
- package/dist/v2/legacy-client.js +19 -2
- package/dist/web/assets/index-B-_gMSn9.js +124 -0
- package/dist/web/assets/index-D5ak5KsI.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-DPjEpD1h.css +0 -2
- 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,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,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
|
+
}
|
package/dist/services/logger.js
CHANGED
|
@@ -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
|
-
|
|
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,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,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
|
+
}>;
|