om-memory-system 3.2.0-next.10 → 3.2.0-next.12

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 (66) hide show
  1. package/dist/adapters/opencode/import-command.d.ts +3 -7
  2. package/dist/adapters/opencode/import-command.js +3 -54
  3. package/dist/adapters/pi/profile.js +2 -1
  4. package/dist/config.d.ts +11 -1
  5. package/dist/config.js +82 -9
  6. package/dist/core/capture.js +2 -1
  7. package/dist/core/profile-analysis.js +9 -1
  8. package/dist/importer/discovery.d.ts +30 -5
  9. package/dist/importer/discovery.js +88 -36
  10. package/dist/importer/import-args.d.ts +23 -1
  11. package/dist/importer/import-args.js +29 -1
  12. package/dist/importer/import-project.d.ts +16 -0
  13. package/dist/importer/import-project.js +35 -0
  14. package/dist/importer/import-readiness.d.ts +47 -0
  15. package/dist/importer/import-readiness.js +54 -0
  16. package/dist/importer/import-sessions.d.ts +98 -0
  17. package/dist/importer/import-sessions.js +222 -0
  18. package/dist/importer/import-sources.d.ts +47 -0
  19. package/dist/importer/import-sources.js +147 -0
  20. package/dist/importer/importer.d.ts +31 -1
  21. package/dist/importer/importer.js +82 -24
  22. package/dist/importer/opencode-import.d.ts +11 -1
  23. package/dist/importer/opencode-import.js +133 -69
  24. package/dist/importer/opencode-project.d.ts +1 -4
  25. package/dist/importer/opencode-project.js +3 -36
  26. package/dist/importer/opencode-reader.d.ts +25 -5
  27. package/dist/importer/opencode-reader.js +92 -106
  28. package/dist/importer/opencode-snapshot.d.ts +82 -0
  29. package/dist/importer/opencode-snapshot.js +276 -0
  30. package/dist/importer/profile-import.d.ts +1 -0
  31. package/dist/importer/profile-import.js +7 -1
  32. package/dist/importer/run-import.d.ts +10 -0
  33. package/dist/importer/run-import.js +21 -0
  34. package/dist/importer/settings-health.d.ts +25 -0
  35. package/dist/importer/settings-health.js +136 -0
  36. package/dist/importer/web-import-api.d.ts +8 -0
  37. package/dist/importer/web-import-api.js +8 -0
  38. package/dist/importer/web-import-jobs.d.ts +53 -0
  39. package/dist/importer/web-import-jobs.js +227 -0
  40. package/dist/index.js +1 -0
  41. package/dist/services/ai/live-model-choice.js +6 -3
  42. package/dist/services/ai/opencode-import-models.d.ts +11 -0
  43. package/dist/services/ai/opencode-import-models.js +55 -0
  44. package/dist/services/capture-attempt-store.d.ts +21 -0
  45. package/dist/services/capture-attempt-store.js +108 -0
  46. package/dist/services/capture-diagnostics.js +3 -0
  47. package/dist/services/cleanup-service.js +9 -0
  48. package/dist/services/global-config-writer.d.ts +10 -0
  49. package/dist/services/global-config-writer.js +106 -0
  50. package/dist/services/settings-log.d.ts +5 -0
  51. package/dist/services/settings-log.js +36 -0
  52. package/dist/services/settings-models.d.ts +35 -0
  53. package/dist/services/settings-models.js +39 -0
  54. package/dist/services/settings-snapshot.d.ts +24 -0
  55. package/dist/services/settings-snapshot.js +71 -0
  56. package/dist/services/settings-traces.d.ts +7 -0
  57. package/dist/services/settings-traces.js +39 -0
  58. package/dist/services/user-memory-learning.js +2 -1
  59. package/dist/services/web-server.d.ts +5 -0
  60. package/dist/services/web-server.js +176 -3
  61. package/dist/web/assets/index-BO6gokx9.js +124 -0
  62. package/dist/web/assets/index-D5ak5KsI.css +2 -0
  63. package/dist/web/index.html +2 -2
  64. package/package.json +2 -1
  65. package/dist/web/assets/index-DPjEpD1h.css +0 -2
  66. package/dist/web/assets/index-do26c4JJ.js +0 -122
@@ -0,0 +1,55 @@
1
+ import { CONFIG } from "../../config.js";
2
+ import { buildBoundedSummaryPrompt } from "../../core/capture-context.js";
3
+ import { loadOpencodeProvider } from "./opencode-provider-loader.js";
4
+ /** Capture and profile calls routed through OpenCode's structured-output sessions. */
5
+ export async function createOpencodeImportModels(ref, directory) {
6
+ const { getV2Client, generateStructuredOutput } = await loadOpencodeProvider();
7
+ const client = getV2Client();
8
+ if (!client)
9
+ throw new Error("the OpenCode client is not ready; retry in a moment");
10
+ const { z } = await import("zod");
11
+ const { buildCaptureSystemPrompt, createUserProfileAnalysisSchema, parseCaptureSummary } = await import("../../core/extraction.js");
12
+ const { detectLanguage, getLanguageName } = await import("../language-detector.js");
13
+ const captureSchema = z.object({
14
+ summary: z.string().optional(),
15
+ type: z.string(),
16
+ tags: z.array(z.string()).optional(),
17
+ });
18
+ const capture = {
19
+ async summarize(request) {
20
+ const target = CONFIG.autoCaptureLanguage && CONFIG.autoCaptureLanguage !== "auto"
21
+ ? CONFIG.autoCaptureLanguage
22
+ : detectLanguage(request.userPrompt);
23
+ const systemPrompt = buildCaptureSystemPrompt(getLanguageName(target));
24
+ const result = await generateStructuredOutput({
25
+ client,
26
+ ...ref,
27
+ systemPrompt,
28
+ userPrompt: buildBoundedSummaryPrompt(request.context, systemPrompt, z.toJSONSchema(captureSchema)),
29
+ schema: captureSchema,
30
+ directory,
31
+ });
32
+ const parsed = parseCaptureSummary(JSON.stringify(result));
33
+ if (!parsed)
34
+ throw new Error("History capture returned an invalid summary");
35
+ return parsed;
36
+ },
37
+ };
38
+ const profileSchema = createUserProfileAnalysisSchema(z);
39
+ const profile = {
40
+ provider: ref.providerID,
41
+ modelId: ref.modelID,
42
+ async complete(systemPrompt, userPrompt) {
43
+ const result = await generateStructuredOutput({
44
+ client,
45
+ ...ref,
46
+ systemPrompt,
47
+ userPrompt,
48
+ schema: profileSchema,
49
+ directory,
50
+ });
51
+ return JSON.stringify(result);
52
+ },
53
+ };
54
+ return { capture, profile };
55
+ }
@@ -0,0 +1,21 @@
1
+ import type { CaptureAttemptRecord } from "./capture-diagnostics.js";
2
+ export declare function saveCaptureAttempt(record: CaptureAttemptRecord, now?: number): Promise<void>;
3
+ export declare function pruneCaptureAttempts(days: number, now?: number): Promise<number>;
4
+ export declare function queryCaptureAttempts(from: number, to: number, limit?: number): Promise<{
5
+ byModel: {
6
+ total: number;
7
+ saved: number;
8
+ skipped: number;
9
+ failed: number;
10
+ }[];
11
+ byReason: {
12
+ host: string;
13
+ provider: unknown;
14
+ model: unknown;
15
+ reason: string;
16
+ count: number;
17
+ }[];
18
+ recent: {
19
+ blockTypes: any;
20
+ }[];
21
+ }>;
@@ -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
+ }
@@ -156,6 +156,9 @@ export function emitCaptureAttempt(record, diagnostics, config, now = new Date()
156
156
  catch {
157
157
  // The log is best effort here, as everywhere else.
158
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) }));
159
162
  if (config.captureTrace === true) {
160
163
  writeTraceEntry(record, diagnostics, config, now);
161
164
  }
@@ -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,106 @@
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
+ ]);
15
+ export class ConfigConflictError extends Error {
16
+ status = 409;
17
+ constructor() {
18
+ super("Config changed. Reload settings and save again.");
19
+ }
20
+ }
21
+ function digest(content) {
22
+ return createHash("sha256").update(content).digest("hex");
23
+ }
24
+ async function snapshot(path) {
25
+ const content = await fs.readFile(path, "utf8");
26
+ const stat = await fs.stat(path);
27
+ return { content, revision: `${stat.mtimeMs}:${stat.size}:${digest(content)}` };
28
+ }
29
+ export function readGlobalConfigRevision() {
30
+ const source = getGlobalConfigSourcePath();
31
+ if (!source)
32
+ return digest(CONFIG_TEMPLATE);
33
+ // Synchronous read keeps the revision tied to the file shown by GET /settings.
34
+ const content = readFileSync(source, "utf8");
35
+ const stat = statSync(source);
36
+ return `${stat.mtimeMs}:${stat.size}:${digest(content)}`;
37
+ }
38
+ let queue = Promise.resolve();
39
+ let pending = 0;
40
+ let lastBaseRevision;
41
+ let lastWrittenRevision;
42
+ /** Write only page-editable keys; reject edits based on a stale config revision. */
43
+ export async function writeGlobalConfigKeys(edits, expectedRevision) {
44
+ if (!edits || !Object.keys(edits).length)
45
+ throw new Error("No settings to save");
46
+ for (const [key, value] of Object.entries(edits)) {
47
+ if (!keys.has(key))
48
+ throw new Error(`Setting ${key} cannot be edited here`);
49
+ if (key === "captureTrace"
50
+ ? typeof value !== "boolean"
51
+ : key.endsWith("RetentionDays")
52
+ ? !Number.isSafeInteger(value) || value < 1
53
+ : typeof value !== "string" || !value.trim()) {
54
+ throw new Error(`Invalid ${key} setting`);
55
+ }
56
+ }
57
+ const wasQueued = pending > 0;
58
+ pending++;
59
+ const save = queue.then(async () => {
60
+ const source = getGlobalConfigSourcePath();
61
+ const target = getGlobalConfigWritePath();
62
+ const original = source
63
+ ? await snapshot(source)
64
+ : { content: CONFIG_TEMPLATE, revision: digest(CONFIG_TEMPLATE) };
65
+ if (original.revision !== expectedRevision &&
66
+ !(wasQueued &&
67
+ lastBaseRevision === expectedRevision &&
68
+ lastWrittenRevision === original.revision)) {
69
+ throw new ConfigConflictError();
70
+ }
71
+ let content = original.content;
72
+ for (const [key, value] of Object.entries(edits)) {
73
+ content = applyEdits(content, modify(content, [key], value, {
74
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
75
+ }));
76
+ }
77
+ const errors = [];
78
+ const parsed = parse(content, errors, { allowTrailingComma: true });
79
+ if (errors.length)
80
+ throw new Error("Config contains invalid JSONC");
81
+ validateGlobalConfig(parsed);
82
+ if (source && (await snapshot(source)).revision !== original.revision) {
83
+ throw new ConfigConflictError();
84
+ }
85
+ await fs.mkdir(dirname(target), { recursive: true });
86
+ const temp = join(dirname(target), `.omms-${randomUUID()}.tmp`);
87
+ try {
88
+ await fs.writeFile(temp, content, { mode: source ? (await fs.stat(source)).mode : 0o600 });
89
+ await fs.rename(temp, target);
90
+ }
91
+ finally {
92
+ await fs.rm(temp, { force: true });
93
+ }
94
+ const revision = (await snapshot(target)).revision;
95
+ lastBaseRevision = expectedRevision;
96
+ lastWrittenRevision = revision;
97
+ return { revision, migratedLegacy: Boolean(source && source !== target) };
98
+ });
99
+ queue = save.then(() => { }, () => { });
100
+ try {
101
+ return await save;
102
+ }
103
+ finally {
104
+ pending--;
105
+ }
106
+ }
@@ -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
+ }>;
@@ -0,0 +1,39 @@
1
+ export async function listOpencodeSettingsModels(client) {
2
+ try {
3
+ const active = client ?? (await import("./ai/opencode-provider.js")).getV2Client();
4
+ if (!active)
5
+ return { available: false, reason: "OpenCode model list unavailable" };
6
+ const result = await active.provider.list();
7
+ if (!result.data)
8
+ return { available: false, reason: "OpenCode model list unavailable" };
9
+ const connected = new Set(result.data.connected);
10
+ const models = result.data.all
11
+ .filter((provider) => connected.has(provider.id))
12
+ .flatMap((provider) => Object.entries(provider.models).map(([model, info]) => ({
13
+ provider: provider.id,
14
+ model,
15
+ name: info.name,
16
+ })));
17
+ return { available: true, models };
18
+ }
19
+ catch {
20
+ return { available: false, reason: "OpenCode model list unavailable" };
21
+ }
22
+ }
23
+ export async function listPiSettingsModels(loadSdk = () => import("@earendil-works/pi-coding-agent")) {
24
+ try {
25
+ const { ModelRuntime } = await loadSdk();
26
+ // Refresh local auth availability without fetching models or writing Pi credentials.
27
+ const runtime = await ModelRuntime.create({ refreshOnCreate: true, allowModelNetwork: false });
28
+ const models = runtime
29
+ .getModels()
30
+ .filter((model) => runtime.hasConfiguredAuth(model.provider))
31
+ .map((model) => ({ provider: model.provider, model: model.id, name: model.name }));
32
+ return models.length
33
+ ? { available: true, models }
34
+ : { available: false, models, reason: "No signed-in Pi models found" };
35
+ }
36
+ catch {
37
+ return { available: false, reason: "Pi model list unavailable; enter provider/model" };
38
+ }
39
+ }
@@ -0,0 +1,24 @@
1
+ export declare function getSettingsSnapshot(directory: string): {
2
+ revision: string;
3
+ settings: {
4
+ [k: string]: {
5
+ value: unknown;
6
+ source: string;
7
+ globalValue: {} | undefined;
8
+ };
9
+ };
10
+ secrets: {
11
+ [k: string]: {
12
+ set: boolean;
13
+ source: string | null;
14
+ };
15
+ };
16
+ fallback: {
17
+ model: string | null;
18
+ configured: boolean;
19
+ };
20
+ effective: {
21
+ opencode: import("./ai/live-model-choice.js").AutoCaptureProviderStatus;
22
+ pi: import("./ai/live-model-choice.js").PiLiveModelChoice;
23
+ };
24
+ };
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "jsonc-parser";
4
+ import { CONFIG, getGlobalConfigSourcePath } from "../config.js";
5
+ import { readGlobalConfigRevision } from "./global-config-writer.js";
6
+ import { getAutoCaptureProviderStatus, isExternalModelReady, resolvePiLiveModel, } from "./ai/live-model-choice.js";
7
+ const editable = [
8
+ "opencodeProvider",
9
+ "opencodeModel",
10
+ "piProvider",
11
+ "piModel",
12
+ "captureTrace",
13
+ "captureTraceRetentionDays",
14
+ "captureAttemptRetentionDays",
15
+ ];
16
+ function readSettingsFile(path) {
17
+ if (!path)
18
+ return {};
19
+ const errors = [];
20
+ const result = parse(readFileSync(path, "utf8"), errors, { allowTrailingComma: true });
21
+ if (errors.length || !result || typeof result !== "object" || Array.isArray(result)) {
22
+ throw new Error("Config file cannot be parsed");
23
+ }
24
+ return result;
25
+ }
26
+ function projectFile(directory) {
27
+ return ["omms.jsonc", "omms.json", "opencode-mem.jsonc", "opencode-mem.json"]
28
+ .map((name) => join(directory, ".opencode", name))
29
+ .find((path) => existsSync(path));
30
+ }
31
+ export function getSettingsSnapshot(directory) {
32
+ const global = readSettingsFile(getGlobalConfigSourcePath());
33
+ const project = readSettingsFile(projectFile(directory));
34
+ const settings = Object.fromEntries(editable.map((key) => {
35
+ const projectOverrides = Object.hasOwn(project, key) &&
36
+ (key === "captureTrace" ? project[key] === false : !key.endsWith("RetentionDays"));
37
+ const source = projectOverrides
38
+ ? "project"
39
+ : Object.hasOwn(global, key)
40
+ ? "global"
41
+ : "default";
42
+ const value = projectOverrides ? project[key] : (CONFIG[key] ?? global[key]);
43
+ return [key, { value, source, globalValue: global[key] ?? CONFIG[key] }];
44
+ }));
45
+ const secrets = Object.fromEntries(["memoryApiKey", "embeddingApiKey", "webServerApiToken", "webServerAuthPassword"].map((key) => {
46
+ const raw = global[key];
47
+ return [
48
+ key,
49
+ {
50
+ set: typeof raw === "string" && raw.length > 0,
51
+ source: typeof raw === "string"
52
+ ? raw.startsWith("env://")
53
+ ? "env"
54
+ : raw.startsWith("file://")
55
+ ? "file"
56
+ : "literal"
57
+ : null,
58
+ },
59
+ ];
60
+ }));
61
+ return {
62
+ revision: readGlobalConfigRevision(),
63
+ settings,
64
+ secrets,
65
+ fallback: { model: CONFIG.memoryModel ?? null, configured: isExternalModelReady(CONFIG) },
66
+ effective: {
67
+ opencode: getAutoCaptureProviderStatus(CONFIG),
68
+ pi: resolvePiLiveModel(CONFIG),
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,7 @@
1
+ export declare function listSettingsTraces(): Promise<{
2
+ file: string;
3
+ date: string | undefined;
4
+ size: number;
5
+ }[]>;
6
+ export declare function readSettingsTrace(file: string): Promise<string>;
7
+ export declare function deleteSettingsTrace(file: string): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import { lstat, readdir, readFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getTraceDirectory } from "./capture-diagnostics.js";
4
+ const traceName = /^capture-(\d{4}-\d{2}-\d{2})\.jsonl$/;
5
+ function tracePath(file) {
6
+ if (!traceName.test(file))
7
+ throw new Error("Invalid trace file name");
8
+ return join(getTraceDirectory(), file);
9
+ }
10
+ export async function listSettingsTraces() {
11
+ let files;
12
+ try {
13
+ files = await readdir(getTraceDirectory());
14
+ }
15
+ catch (error) {
16
+ if (error.code === "ENOENT")
17
+ return [];
18
+ throw error;
19
+ }
20
+ const traces = await Promise.all(files
21
+ .filter((file) => traceName.test(file))
22
+ .map(async (file) => {
23
+ const stats = await lstat(tracePath(file));
24
+ return stats.isFile() ? { file, date: traceName.exec(file)[1], size: stats.size } : null;
25
+ }));
26
+ return traces.filter((trace) => trace !== null).sort((a, b) => b.file.localeCompare(a.file));
27
+ }
28
+ export async function readSettingsTrace(file) {
29
+ const path = tracePath(file);
30
+ if (!(await lstat(path)).isFile())
31
+ throw new Error("Invalid trace file");
32
+ return readFile(path, "utf8");
33
+ }
34
+ export async function deleteSettingsTrace(file) {
35
+ const path = tracePath(file);
36
+ if (!(await lstat(path)).isFile())
37
+ throw new Error("Invalid trace file");
38
+ await unlink(path);
39
+ }
@@ -1,6 +1,6 @@
1
1
  import { getTags } from "./tags.js";
2
2
  import { log } from "./logger.js";
3
- import { CONFIG } from "../config.js";
3
+ import { CONFIG, refreshConfigIfChanged } from "../config.js";
4
4
  import { resolveOpencodeHostModel } from "./ai/live-model-choice.js";
5
5
  import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
6
6
  import { userProfileManager } from "./user-profile/user-profile-manager.js";
@@ -42,6 +42,7 @@ async function runAutomaticProfileCleanup(userId) {
42
42
  export async function performUserProfileLearning(ctx, directory) {
43
43
  if (isLearningRunning)
44
44
  return;
45
+ refreshConfigIfChanged(directory);
45
46
  if (!CONFIG.autoCaptureProviderStatus || !CONFIG.autoCaptureProviderStatus.ready) {
46
47
  log("user-profile-learning: skipped (provider not ready)", {
47
48
  issues: CONFIG.autoCaptureProviderStatus?.issues ?? ["status undefined"],
@@ -19,6 +19,7 @@ export declare function nextFallbackPort(currentPort: number, failedTakeovers: n
19
19
  interface WebServerConfig {
20
20
  port: number;
21
21
  host: string;
22
+ directory?: string;
22
23
  enabled: boolean;
23
24
  auth?: WebAuth;
24
25
  apiToken?: string;
@@ -34,6 +35,10 @@ export declare class WebServer {
34
35
  private portsExhaustedNotified;
35
36
  private takeoverFailures;
36
37
  private readonly maxFallbackPort;
38
+ private settingsImportJobs?;
39
+ /** Run an import-page action; errors carry their own status and never file contents. */
40
+ private importResponse;
41
+ private importJobs;
37
42
  constructor(config: WebServerConfig);
38
43
  setOnTakeoverCallback(callback: () => Promise<void>): void;
39
44
  setOnPortsExhaustedCallback(callback: () => void): void;