om-memory-system 3.2.0-next.8 → 3.3.0-next.17

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 (89) hide show
  1. package/README.md +4 -1
  2. package/dist/adapters/opencode/auto-capture-summary.js +50 -8
  3. package/dist/adapters/opencode/import-command.d.ts +3 -7
  4. package/dist/adapters/opencode/import-command.js +3 -54
  5. package/dist/adapters/pi/extension.js +3 -0
  6. package/dist/adapters/pi/profile.js +2 -1
  7. package/dist/adapters/pi/provider.js +41 -14
  8. package/dist/config.d.ts +18 -1
  9. package/dist/config.js +103 -9
  10. package/dist/core/capture-context.js +1 -1
  11. package/dist/core/capture.d.ts +4 -0
  12. package/dist/core/capture.js +30 -0
  13. package/dist/core/extraction.d.ts +23 -1
  14. package/dist/core/extraction.js +41 -0
  15. package/dist/core/host.d.ts +23 -0
  16. package/dist/core/host.js +9 -1
  17. package/dist/core/profile-analysis.js +9 -1
  18. package/dist/importer/discovery.d.ts +32 -5
  19. package/dist/importer/discovery.js +88 -36
  20. package/dist/importer/import-args.d.ts +23 -1
  21. package/dist/importer/import-args.js +29 -1
  22. package/dist/importer/import-project.d.ts +16 -0
  23. package/dist/importer/import-project.js +35 -0
  24. package/dist/importer/import-readiness.d.ts +47 -0
  25. package/dist/importer/import-readiness.js +54 -0
  26. package/dist/importer/import-sessions.d.ts +98 -0
  27. package/dist/importer/import-sessions.js +222 -0
  28. package/dist/importer/import-sources.d.ts +47 -0
  29. package/dist/importer/import-sources.js +147 -0
  30. package/dist/importer/importer.d.ts +31 -1
  31. package/dist/importer/importer.js +82 -24
  32. package/dist/importer/opencode-import.d.ts +11 -1
  33. package/dist/importer/opencode-import.js +133 -69
  34. package/dist/importer/opencode-project.d.ts +1 -4
  35. package/dist/importer/opencode-project.js +3 -36
  36. package/dist/importer/opencode-reader.d.ts +25 -5
  37. package/dist/importer/opencode-reader.js +92 -106
  38. package/dist/importer/opencode-snapshot.d.ts +82 -0
  39. package/dist/importer/opencode-snapshot.js +276 -0
  40. package/dist/importer/profile-import.d.ts +1 -0
  41. package/dist/importer/profile-import.js +7 -1
  42. package/dist/importer/run-import.d.ts +10 -0
  43. package/dist/importer/run-import.js +21 -0
  44. package/dist/importer/settings-health.d.ts +25 -0
  45. package/dist/importer/settings-health.js +136 -0
  46. package/dist/importer/web-import-api.d.ts +8 -0
  47. package/dist/importer/web-import-api.js +8 -0
  48. package/dist/importer/web-import-jobs.d.ts +53 -0
  49. package/dist/importer/web-import-jobs.js +227 -0
  50. package/dist/index.js +4 -0
  51. package/dist/services/ai/live-model-choice.js +6 -3
  52. package/dist/services/ai/opencode-import-models.d.ts +11 -0
  53. package/dist/services/ai/opencode-import-models.js +55 -0
  54. package/dist/services/ai/opencode-provider.d.ts +9 -0
  55. package/dist/services/ai/opencode-provider.js +31 -4
  56. package/dist/services/ai/providers/anthropic-messages.js +14 -6
  57. package/dist/services/ai/providers/base-provider.d.ts +8 -0
  58. package/dist/services/ai/providers/base-provider.js +12 -0
  59. package/dist/services/ai/providers/google-gemini.js +21 -7
  60. package/dist/services/ai/providers/openai-chat-completion.js +20 -7
  61. package/dist/services/ai/providers/openai-responses.js +11 -0
  62. package/dist/services/capture-attempt-store.d.ts +21 -0
  63. package/dist/services/capture-attempt-store.js +108 -0
  64. package/dist/services/capture-diagnostics.d.ts +66 -0
  65. package/dist/services/capture-diagnostics.js +175 -0
  66. package/dist/services/cleanup-service.js +9 -0
  67. package/dist/services/global-config-writer.d.ts +10 -0
  68. package/dist/services/global-config-writer.js +106 -0
  69. package/dist/services/log-path.d.ts +2 -0
  70. package/dist/services/log-path.js +15 -0
  71. package/dist/services/logger.js +1 -12
  72. package/dist/services/settings-log.d.ts +5 -0
  73. package/dist/services/settings-log.js +36 -0
  74. package/dist/services/settings-models.d.ts +35 -0
  75. package/dist/services/settings-models.js +39 -0
  76. package/dist/services/settings-snapshot.d.ts +24 -0
  77. package/dist/services/settings-snapshot.js +71 -0
  78. package/dist/services/settings-traces.d.ts +7 -0
  79. package/dist/services/settings-traces.js +39 -0
  80. package/dist/services/user-memory-learning.js +2 -1
  81. package/dist/services/web-server.d.ts +5 -0
  82. package/dist/services/web-server.js +176 -3
  83. package/dist/v2/legacy-client.js +19 -2
  84. package/dist/web/assets/index-BMkPht7g.js +124 -0
  85. package/dist/web/assets/index-D5ak5KsI.css +2 -0
  86. package/dist/web/index.html +2 -2
  87. package/package.json +2 -1
  88. package/dist/web/assets/index-DPjEpD1h.css +0 -2
  89. package/dist/web/assets/index-do26c4JJ.js +0 -122
@@ -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,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,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,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;