om-memory-system 3.7.0-next.49 → 3.7.0-next.52

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 (64) hide show
  1. package/dist/adapters/opencode/package-skills.d.ts +16 -0
  2. package/dist/adapters/opencode/package-skills.js +60 -0
  3. package/dist/adapters/opencode/profile-learning.js +9 -2
  4. package/dist/adapters/pi/extension.js +5 -1
  5. package/dist/adapters/pi/profile.js +7 -1
  6. package/dist/cli/index.js +5 -0
  7. package/dist/cli/profile-catch-up-command.d.ts +2 -0
  8. package/dist/cli/profile-catch-up-command.js +110 -0
  9. package/dist/cli/web-command.js +3 -6
  10. package/dist/config.d.ts +1 -1
  11. package/dist/config.js +6 -4
  12. package/dist/core/memory-tool-text.d.ts +4 -0
  13. package/dist/core/memory-tool-text.js +6 -0
  14. package/dist/core/profile-backoff.d.ts +20 -0
  15. package/dist/core/profile-backoff.js +41 -0
  16. package/dist/core/profile-failure.d.ts +16 -0
  17. package/dist/core/profile-failure.js +34 -0
  18. package/dist/core/trivial-prompt.d.ts +5 -0
  19. package/dist/core/trivial-prompt.js +8 -0
  20. package/dist/importer/claude-hook-api.js +13 -2
  21. package/dist/importer/model-selection.d.ts +2 -0
  22. package/dist/importer/model-selection.js +15 -4
  23. package/dist/importer/profile-backlog.d.ts +42 -0
  24. package/dist/importer/profile-backlog.js +79 -0
  25. package/dist/importer/profile-catch-up.d.ts +39 -0
  26. package/dist/importer/profile-catch-up.js +123 -0
  27. package/dist/importer/profile-import.d.ts +1 -1
  28. package/dist/importer/profile-import.js +14 -35
  29. package/dist/importer/settings-health.js +50 -11
  30. package/dist/importer/web-import-api.d.ts +1 -0
  31. package/dist/importer/web-import-api.js +1 -0
  32. package/dist/index.js +4 -1
  33. package/dist/services/api-tokens.d.ts +35 -0
  34. package/dist/services/api-tokens.js +185 -0
  35. package/dist/services/capture-attempt-store.d.ts +3 -1
  36. package/dist/services/capture-attempt-store.js +12 -5
  37. package/dist/services/context.js +4 -1
  38. package/dist/services/embedding-change.d.ts +52 -0
  39. package/dist/services/embedding-change.js +177 -0
  40. package/dist/services/embedding.d.ts +23 -1
  41. package/dist/services/embedding.js +63 -24
  42. package/dist/services/global-config-writer.d.ts +12 -1
  43. package/dist/services/global-config-writer.js +32 -4
  44. package/dist/services/migration-service.js +4 -1
  45. package/dist/services/settings-snapshot.d.ts +8 -0
  46. package/dist/services/settings-snapshot.js +11 -0
  47. package/dist/services/user-prompt/profile-catch-up-lease.d.ts +34 -0
  48. package/dist/services/user-prompt/profile-catch-up-lease.js +77 -0
  49. package/dist/services/user-prompt/user-prompt-manager.d.ts +14 -1
  50. package/dist/services/user-prompt/user-prompt-manager.js +36 -1
  51. package/dist/services/web-api-auth.d.ts +6 -2
  52. package/dist/services/web-api-auth.js +11 -9
  53. package/dist/services/web-password.d.ts +11 -0
  54. package/dist/services/web-password.js +30 -0
  55. package/dist/services/web-server.d.ts +8 -0
  56. package/dist/services/web-server.js +182 -19
  57. package/dist/v2/adapter.js +3 -0
  58. package/dist/web/assets/index-B6LyHM22.css +2 -0
  59. package/dist/web/assets/index-CVvXO3zL.js +125 -0
  60. package/dist/web/index.html +2 -2
  61. package/package.json +5 -1
  62. package/skills/omms-memory/SKILL.md +93 -0
  63. package/dist/web/assets/index-Bh5aFNPX.js +0 -124
  64. package/dist/web/assets/index-DG7RjW5B.css +0 -2
@@ -75,19 +75,62 @@ export async function loadLocalTransformersBackend() {
75
75
  return ensureTransformersLoaded();
76
76
  }
77
77
  function withTimeout(promise, ms) {
78
+ let timer;
78
79
  return Promise.race([
79
80
  promise,
80
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)),
81
+ new Promise((_, reject) => {
82
+ timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
83
+ }),
84
+ ]).finally(() => clearTimeout(timer));
85
+ }
86
+ /**
87
+ * The server request for an OpenAI-compatible embedder, or null for the
88
+ * built-in model. The URL alone selects the server; the key is optional.
89
+ */
90
+ export function embeddingServerRequest(settings) {
91
+ if (!settings.embeddingApiUrl)
92
+ return null;
93
+ const headers = { "Content-Type": "application/json" };
94
+ if (settings.embeddingApiKey)
95
+ headers.Authorization = `Bearer ${settings.embeddingApiKey}`;
96
+ return { url: `${settings.embeddingApiUrl}/embeddings`, headers };
97
+ }
98
+ /** Changes whenever the embedder or its input text would change. */
99
+ export function embeddingSettingsSignature(settings) {
100
+ return JSON.stringify([
101
+ settings.embeddingApiUrl ?? "",
102
+ settings.embeddingModel,
103
+ settings.embeddingApiKey ?? "",
104
+ settings.embeddingUseTaskPrefixes ?? false,
81
105
  ]);
82
106
  }
83
107
  export class EmbeddingService {
108
+ settings;
84
109
  pipe = null;
85
110
  initPromise = null;
86
111
  isWarmedUp = false;
87
112
  /** Set when warmup fails permanently; prevents "initializing forever" (#184). */
88
113
  initError = null;
89
114
  cache = new Map();
90
- cachedModelName = null;
115
+ signature = null;
116
+ /** `settings` is read on every call, so a config reload reaches the next embed. */
117
+ constructor(settings = () => CONFIG) {
118
+ this.settings = settings;
119
+ }
120
+ /** Drop the loaded model, warm state, and cache when the embedder settings change. */
121
+ resetIfSettingsChanged() {
122
+ const signature = embeddingSettingsSignature(this.settings());
123
+ if (this.signature === signature)
124
+ return;
125
+ if (this.signature !== null) {
126
+ this.pipe = null;
127
+ this.initPromise = null;
128
+ this.isWarmedUp = false;
129
+ this.initError = null;
130
+ }
131
+ this.clearCache();
132
+ this.signature = signature;
133
+ }
91
134
  static getInstance() {
92
135
  if (!globalThis[GLOBAL_EMBEDDING_KEY]) {
93
136
  globalThis[GLOBAL_EMBEDDING_KEY] = new EmbeddingService();
@@ -95,6 +138,7 @@ export class EmbeddingService {
95
138
  return globalThis[GLOBAL_EMBEDDING_KEY];
96
139
  }
97
140
  async warmup(progressCallback) {
141
+ this.resetIfSettingsChanged();
98
142
  if (this.isWarmedUp)
99
143
  return;
100
144
  if (this.initError)
@@ -105,19 +149,18 @@ export class EmbeddingService {
105
149
  return this.initPromise;
106
150
  }
107
151
  async initializeModel(progressCallback) {
152
+ const settings = this.settings();
108
153
  try {
109
- if (CONFIG.embeddingApiUrl && CONFIG.embeddingApiKey) {
154
+ const server = embeddingServerRequest(settings);
155
+ if (server) {
110
156
  // Send a probe request to verify the API endpoint is actually reachable
111
157
  // Uses a minimal embedding of "ping" to test the full request pipeline
112
- const probeResponse = await withTimeout(fetch(`${CONFIG.embeddingApiUrl}/embeddings`, {
158
+ const probeResponse = await withTimeout(fetch(server.url, {
113
159
  method: "POST",
114
- headers: {
115
- "Content-Type": "application/json",
116
- Authorization: `Bearer ${CONFIG.embeddingApiKey}`,
117
- },
160
+ headers: server.headers,
118
161
  body: JSON.stringify({
119
162
  input: "ping",
120
- model: CONFIG.embeddingModel,
163
+ model: settings.embeddingModel,
121
164
  }),
122
165
  }), TIMEOUT_MS);
123
166
  if (!probeResponse.ok) {
@@ -129,12 +172,12 @@ export class EmbeddingService {
129
172
  }
130
173
  // Local model path
131
174
  const { pipeline } = await ensureTransformersLoaded();
132
- this.pipe = await pipeline("feature-extraction", CONFIG.embeddingModel, {
175
+ this.pipe = await pipeline("feature-extraction", settings.embeddingModel, {
133
176
  progress_callback: progressCallback,
134
177
  });
135
178
  this.isWarmedUp = true;
136
179
  this.initError = null;
137
- log("Embedding model warmed up", { model: CONFIG.embeddingModel });
180
+ log("Embedding model warmed up", { model: settings.embeddingModel });
138
181
  }
139
182
  catch (error) {
140
183
  const rewritten = formatOnnxruntimeInitError(error);
@@ -145,11 +188,9 @@ export class EmbeddingService {
145
188
  }
146
189
  }
147
190
  async embed(text, options) {
148
- if (this.cachedModelName !== CONFIG.embeddingModel) {
149
- this.clearCache();
150
- this.cachedModelName = CONFIG.embeddingModel;
151
- }
152
- const input = applyEmbeddingTaskPrefix(text, options);
191
+ this.resetIfSettingsChanged();
192
+ const settings = this.settings();
193
+ const input = applyEmbeddingTaskPrefix(text, options, settings.embeddingUseTaskPrefixes ?? false);
153
194
  const cached = this.cache.get(input);
154
195
  if (cached)
155
196
  return cached;
@@ -160,20 +201,18 @@ export class EmbeddingService {
160
201
  await this.initPromise;
161
202
  }
162
203
  let result;
163
- if (CONFIG.embeddingApiUrl && CONFIG.embeddingApiKey) {
164
- const response = await fetch(`${CONFIG.embeddingApiUrl}/embeddings`, {
204
+ const server = embeddingServerRequest(settings);
205
+ if (server) {
206
+ const response = await fetch(server.url, {
165
207
  method: "POST",
166
- headers: {
167
- "Content-Type": "application/json",
168
- Authorization: `Bearer ${CONFIG.embeddingApiKey}`,
169
- },
208
+ headers: server.headers,
170
209
  body: JSON.stringify({
171
210
  input,
172
- model: CONFIG.embeddingModel,
211
+ model: settings.embeddingModel,
173
212
  }),
174
213
  });
175
214
  if (!response.ok) {
176
- throw new Error(`API embedding failed: ${response.statusText}`);
215
+ throw new Error(`API embedding failed: ${response.status} ${response.statusText}`);
177
216
  }
178
217
  const data = await response.json();
179
218
  result = new Float32Array(data.data[0].embedding);
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Written only by the embedding apply route, together, after a passing test.
3
+ * The general settings save refuses them, so a change always re-embeds.
4
+ */
5
+ export declare const EMBEDDING_KEYS: Set<string>;
6
+ /** Written only by the browser password route; the password itself lives in a private key file. */
7
+ export declare const PASSWORD_KEYS: Set<string>;
1
8
  /** The page may point `memoryApiKey` at a source, never store the key itself. */
2
9
  export declare function isSecretReference(value: unknown): value is string;
3
10
  export declare class ConfigConflictError extends Error {
@@ -6,7 +13,11 @@ export declare class ConfigConflictError extends Error {
6
13
  }
7
14
  export declare function readGlobalConfigRevision(): string;
8
15
  /** Write only page-editable keys; reject edits based on a stale config revision. */
9
- export declare function writeGlobalConfigKeys(edits: Record<string, unknown>, expectedRevision: string): Promise<{
16
+ export declare function writeGlobalConfigKeys(edits: Record<string, unknown>, expectedRevision: string,
17
+ /** A route that owns a separate set of keys passes it; the general save may not touch them. */
18
+ options?: {
19
+ only?: Set<string>;
20
+ }): Promise<{
10
21
  revision: string;
11
22
  migratedLegacy: boolean;
12
23
  }>;
@@ -24,6 +24,18 @@ const keys = new Set([
24
24
  "importPathMaps",
25
25
  "claudeConfigDir",
26
26
  ]);
27
+ /**
28
+ * Written only by the embedding apply route, together, after a passing test.
29
+ * The general settings save refuses them, so a change always re-embeds.
30
+ */
31
+ export const EMBEDDING_KEYS = new Set([
32
+ "embeddingApiUrl",
33
+ "embeddingApiKey",
34
+ "embeddingModel",
35
+ "embeddingDimensions",
36
+ ]);
37
+ /** Written only by the browser password route; the password itself lives in a private key file. */
38
+ export const PASSWORD_KEYS = new Set(["webServerAuthPassword", "webServerAuthUsername"]);
27
39
  const MEMORY_PROVIDERS = [
28
40
  "openai-chat",
29
41
  "openai-responses",
@@ -37,6 +49,19 @@ export function isSecretReference(value) {
37
49
  return (typeof value === "string" && /^(env:\/\/[A-Za-z_][A-Za-z0-9_]*|file:\/\/.+)$/.test(value.trim()));
38
50
  }
39
51
  function isValidEdit(key, value) {
52
+ // `undefined` removes the key from the file.
53
+ if (key === "embeddingApiUrl") {
54
+ return value === undefined || (typeof value === "string" && /^https?:\/\/\S+$/.test(value));
55
+ }
56
+ if (key === "embeddingApiKey" || key === "webServerAuthPassword") {
57
+ return value === undefined || isSecretReference(value);
58
+ }
59
+ if (key === "webServerAuthUsername") {
60
+ return value === undefined || (typeof value === "string" && value.trim().length > 0);
61
+ }
62
+ if (key === "embeddingDimensions") {
63
+ return Number.isInteger(value) && value > 0 && value <= 65536;
64
+ }
40
65
  if (key === "captureTrace" || key === "autoBackfill" || key === "webServerAutoStart") {
41
66
  return typeof value === "boolean";
42
67
  }
@@ -85,16 +110,19 @@ let pending = 0;
85
110
  let lastBaseRevision;
86
111
  let lastWrittenRevision;
87
112
  /** Write only page-editable keys; reject edits based on a stale config revision. */
88
- export async function writeGlobalConfigKeys(edits, expectedRevision) {
113
+ export async function writeGlobalConfigKeys(edits, expectedRevision,
114
+ /** A route that owns a separate set of keys passes it; the general save may not touch them. */
115
+ options = {}) {
89
116
  if (!edits || !Object.keys(edits).length)
90
117
  throw new Error("No settings to save");
91
118
  for (const [key, value] of Object.entries(edits)) {
92
- if (!keys.has(key))
119
+ const allowed = options.only ? options.only.has(key) : keys.has(key);
120
+ if (!allowed)
93
121
  throw new Error(`Setting ${key} cannot be edited here`);
94
122
  if (!isValidEdit(key, value)) {
95
123
  // Never echo the value: a rejected memoryApiKey may be a literal key.
96
- throw new Error(key === "memoryApiKey"
97
- ? "memoryApiKey must be an env:// or file:// reference"
124
+ throw new Error(["memoryApiKey", "embeddingApiKey", "webServerAuthPassword"].includes(key)
125
+ ? `${key} must be an env:// or file:// reference`
98
126
  : `Invalid ${key} setting`);
99
127
  }
100
128
  }
@@ -28,7 +28,10 @@ export class MigrationService {
28
28
  const metadata = Object.fromEntries(metadataResult.map((row) => [row.key, row.value]));
29
29
  const storedDimensions = parseInt(String(metadata.embedding_dimensions || "0"));
30
30
  const storedModel = String(metadata.embedding_model || "unknown");
31
- if (storedDimensions !== CONFIG.embeddingDimensions) {
31
+ // An old shard with no known model is flagged only on size, so an upgrade does not force a re-embed.
32
+ const knownModel = !["", "unknown", "legacy-unknown"].includes(storedModel);
33
+ const modelChanged = knownModel && storedModel !== CONFIG.embeddingModel;
34
+ if (storedDimensions !== CONFIG.embeddingDimensions || modelChanged) {
32
35
  const vectorCount = await tursoVectorSearch.countAllVectors(db);
33
36
  mismatches.push({
34
37
  shardId: shard.id,
@@ -34,6 +34,14 @@ export declare function getSettingsSnapshot(directory: string): {
34
34
  source: import("./claude-folder.js").ClaudeFolderSource;
35
35
  exists: boolean;
36
36
  };
37
+ access: {
38
+ host: string;
39
+ authEnabled: boolean;
40
+ authUsername: string | null;
41
+ tokenAvailable: boolean;
42
+ embeddingApiUrl: string | null;
43
+ configTokenIgnored: boolean;
44
+ };
37
45
  };
38
46
  /**
39
47
  * Where `memoryApiKey` comes from and whether it resolves in this web
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse } from "jsonc-parser";
4
4
  import { CONFIG, getGlobalConfigSourcePath } from "../config.js";
5
+ import { hasUnexpiredApiToken } from "./api-tokens.js";
5
6
  import { resolveClaudeFolder } from "./claude-folder.js";
6
7
  import { readGlobalConfigRevision } from "./global-config-writer.js";
7
8
  import { getAutoCaptureProviderStatus, isExternalModelReady, resolveClaudeCodeLiveModel, resolvePiLiveModel, } from "./ai/live-model-choice.js";
@@ -103,6 +104,16 @@ export function getSettingsSnapshot(directory) {
103
104
  },
104
105
  // The folder Claude Code capture and import read; the check never reads transcripts.
105
106
  claudeFolder: { root: claudeRoot, source: claude.source, exists: existsSync(claudeRoot) },
107
+ // What the Keys and access card needs to mark each credential; no secret values.
108
+ access: {
109
+ host: CONFIG.webServerHost,
110
+ authEnabled: Boolean(CONFIG.webServerAuthPassword),
111
+ authUsername: CONFIG.webServerAuthUsername ?? null,
112
+ tokenAvailable: hasUnexpiredApiToken(),
113
+ embeddingApiUrl: CONFIG.embeddingApiUrl ?? null,
114
+ // The config key is imported once, then no longer read.
115
+ configTokenIgnored: typeof global.webServerApiToken === "string" && global.webServerApiToken !== "",
116
+ },
106
117
  };
107
118
  }
108
119
  /**
@@ -0,0 +1,34 @@
1
+ /** One batch with its retry at the 120-second limit fits well inside this. */
2
+ export declare const LEASE_EXPIRY_MS: number;
3
+ export type BatchStart = "ok" | "superseded" | "aborted";
4
+ /**
5
+ * The catch-up run record, shared by the web app and the terminal command
6
+ * through `user-prompts.db`. The newest run owns the record. A run sends a
7
+ * batch only while it owns the record and no other run is mid-batch, so no
8
+ * batch is sent twice. A record not refreshed for 10 minutes has expired.
9
+ */
10
+ export declare class CatchUpLease {
11
+ private readonly options;
12
+ private prepared;
13
+ private readonly now;
14
+ private readonly sleep;
15
+ constructor(options?: {
16
+ path?: string;
17
+ now?: () => number;
18
+ sleep?: (ms: number) => Promise<void>;
19
+ });
20
+ private db;
21
+ /** Become the owner. The previous owner's mid-batch mark stays until its batch ends. */
22
+ take(owner: string): Promise<void>;
23
+ /** Wait until this run may send its next batch, or report that a newer run took over. */
24
+ beginBatch(owner: string, options?: {
25
+ signal?: AbortSignal;
26
+ onWait?: () => void;
27
+ }): Promise<BatchStart>;
28
+ /** Clear this run's mid-batch mark, also after a newer run took over. */
29
+ endBatch(owner: string): Promise<void>;
30
+ /** Remove the record only when this run still owns it. */
31
+ release(owner: string): Promise<void>;
32
+ /** True while an unexpired record exists; a live pass skips meanwhile. */
33
+ isActive(): Promise<boolean>;
34
+ }
@@ -0,0 +1,77 @@
1
+ import { join } from "node:path";
2
+ import { CONFIG } from "../../config.js";
3
+ import { tursoConnectionManager } from "../turso/connection-manager.js";
4
+ /** One batch with its retry at the 120-second limit fits well inside this. */
5
+ export const LEASE_EXPIRY_MS = 10 * 60 * 1000;
6
+ const WAIT_MS = 1000;
7
+ /**
8
+ * The catch-up run record, shared by the web app and the terminal command
9
+ * through `user-prompts.db`. The newest run owns the record. A run sends a
10
+ * batch only while it owns the record and no other run is mid-batch, so no
11
+ * batch is sent twice. A record not refreshed for 10 minutes has expired.
12
+ */
13
+ export class CatchUpLease {
14
+ options;
15
+ prepared = null;
16
+ now;
17
+ sleep;
18
+ constructor(options = {}) {
19
+ this.options = options;
20
+ this.now = options.now ?? Date.now;
21
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
22
+ }
23
+ db() {
24
+ this.prepared ??= (async () => {
25
+ const path = this.options.path ?? join(CONFIG.storagePath, "user-prompts.db");
26
+ const db = await tursoConnectionManager.getConnection(path);
27
+ await db.run(`CREATE TABLE IF NOT EXISTS profile_catch_up_lease (
28
+ id INTEGER PRIMARY KEY CHECK (id = 1),
29
+ owner TEXT NOT NULL, busy_owner TEXT, refreshed_at INTEGER NOT NULL
30
+ )`);
31
+ return db;
32
+ })();
33
+ return this.prepared;
34
+ }
35
+ /** Become the owner. The previous owner's mid-batch mark stays until its batch ends. */
36
+ async take(owner) {
37
+ const db = await this.db();
38
+ await db.run(`INSERT INTO profile_catch_up_lease (id, owner, busy_owner, refreshed_at)
39
+ VALUES (1, ?, NULL, ?)
40
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner`, [owner, this.now()]);
41
+ }
42
+ /** Wait until this run may send its next batch, or report that a newer run took over. */
43
+ async beginBatch(owner, options = {}) {
44
+ const db = await this.db();
45
+ for (;;) {
46
+ if (options.signal?.aborted)
47
+ return "aborted";
48
+ const now = this.now();
49
+ const changed = await db.run(`UPDATE profile_catch_up_lease SET busy_owner = ?, refreshed_at = ?
50
+ WHERE id = 1 AND owner = ?
51
+ AND (busy_owner IS NULL OR busy_owner = ? OR refreshed_at < ?)`, [owner, now, owner, owner, now - LEASE_EXPIRY_MS]);
52
+ if (changed > 0)
53
+ return "ok";
54
+ const row = await db.get("SELECT owner FROM profile_catch_up_lease WHERE id = 1");
55
+ if (!row || String(row.owner) !== owner)
56
+ return "superseded";
57
+ options.onWait?.();
58
+ await this.sleep(WAIT_MS);
59
+ }
60
+ }
61
+ /** Clear this run's mid-batch mark, also after a newer run took over. */
62
+ async endBatch(owner) {
63
+ const db = await this.db();
64
+ await db.run("UPDATE profile_catch_up_lease SET busy_owner = NULL, refreshed_at = ? WHERE busy_owner = ?", [this.now(), owner]);
65
+ }
66
+ /** Remove the record only when this run still owns it. */
67
+ async release(owner) {
68
+ const db = await this.db();
69
+ await db.run("DELETE FROM profile_catch_up_lease WHERE id = 1 AND owner = ?", [owner]);
70
+ }
71
+ /** True while an unexpired record exists; a live pass skips meanwhile. */
72
+ async isActive() {
73
+ const db = await this.db();
74
+ const row = await db.get("SELECT 1 FROM profile_catch_up_lease WHERE id = 1 AND refreshed_at >= ?", [this.now() - LEASE_EXPIRY_MS]);
75
+ return Boolean(row);
76
+ }
77
+ }
@@ -34,7 +34,20 @@ export declare class UserPromptManager {
34
34
  getUncapturedPrompts(limit: number): Promise<UserPrompt[]>;
35
35
  markMultipleAsCaptured(promptIds: string[]): Promise<void>;
36
36
  countUnanalyzedForUserLearning(): Promise<number>;
37
- getPromptsForUserLearning(limit: number): Promise<UserPrompt[]>;
37
+ /**
38
+ * Waiting prompts, oldest first. With `recentFirst`, a live pass takes the
39
+ * prompts of the last 7 days newest first, so an imported backlog cannot
40
+ * starve it; with none waiting it falls back to the oldest.
41
+ */
42
+ getPromptsForUserLearning(limit: number, options?: {
43
+ recentFirst?: boolean;
44
+ now?: number;
45
+ }): Promise<UserPrompt[]>;
46
+ /** Mark waiting trivial prompts learned without a model call; returns how many. */
47
+ skipTrivialPromptsForLearning(): Promise<number>;
48
+ /** Waiting trivial prompts, counted without changing them; a preview uses this. */
49
+ countTrivialPromptsForLearning(): Promise<number>;
50
+ private waitingTrivialPromptIds;
38
51
  markAsUserLearningCaptured(promptId: string): Promise<void>;
39
52
  markMultipleAsUserLearningCaptured(promptIds: string[]): Promise<void>;
40
53
  deleteOldPrompts(cutoffTime: number): Promise<{
@@ -1,6 +1,8 @@
1
1
  import { join } from "node:path";
2
2
  import { tursoConnectionManager } from "../turso/connection-manager.js";
3
3
  import { CONFIG } from "../../config.js";
4
+ import { isTrivialPrompt } from "../../core/trivial-prompt.js";
5
+ const RECENT_LEARNING_WINDOW_MS = 7 * 86_400_000;
4
6
  const USER_PROMPTS_DB_NAME = "user-prompts.db";
5
7
  export class UserPromptManager {
6
8
  db = null;
@@ -197,8 +199,24 @@ export class UserPromptManager {
197
199
  const row = await db.get(`SELECT COUNT(*) as count FROM user_prompts WHERE user_learning_captured = 0`);
198
200
  return Number(row?.count ?? 0);
199
201
  }
200
- async getPromptsForUserLearning(limit) {
202
+ /**
203
+ * Waiting prompts, oldest first. With `recentFirst`, a live pass takes the
204
+ * prompts of the last 7 days newest first, so an imported backlog cannot
205
+ * starve it; with none waiting it falls back to the oldest.
206
+ */
207
+ async getPromptsForUserLearning(limit, options = {}) {
201
208
  const db = await this.ready();
209
+ if (options.recentFirst) {
210
+ const since = (options.now ?? Date.now()) - RECENT_LEARNING_WINDOW_MS;
211
+ const recent = await db.all(`
212
+ SELECT * FROM user_prompts
213
+ WHERE user_learning_captured = 0 AND created_at >= ?
214
+ ORDER BY created_at DESC
215
+ LIMIT ?
216
+ `, [since, limit]);
217
+ if (recent.length > 0)
218
+ return recent.map((row) => this.rowToPrompt(row));
219
+ }
202
220
  const rows = await db.all(`
203
221
  SELECT * FROM user_prompts
204
222
  WHERE user_learning_captured = 0
@@ -207,6 +225,23 @@ export class UserPromptManager {
207
225
  `, [limit]);
208
226
  return rows.map((row) => this.rowToPrompt(row));
209
227
  }
228
+ /** Mark waiting trivial prompts learned without a model call; returns how many. */
229
+ async skipTrivialPromptsForLearning() {
230
+ const ids = await this.waitingTrivialPromptIds();
231
+ await this.markMultipleAsUserLearningCaptured(ids);
232
+ return ids.length;
233
+ }
234
+ /** Waiting trivial prompts, counted without changing them; a preview uses this. */
235
+ async countTrivialPromptsForLearning() {
236
+ return (await this.waitingTrivialPromptIds()).length;
237
+ }
238
+ async waitingTrivialPromptIds() {
239
+ const db = await this.ready();
240
+ const rows = await db.all(`SELECT id, content FROM user_prompts
241
+ WHERE user_learning_captured = 0
242
+ AND length(trim(content, ' ' || char(9, 10, 11, 12, 13))) < 20`);
243
+ return rows.filter((row) => isTrivialPrompt(String(row.content))).map((row) => String(row.id));
244
+ }
210
245
  async markAsUserLearningCaptured(promptId) {
211
246
  const db = await this.ready();
212
247
  await db.run(`UPDATE user_prompts SET user_learning_captured = 1 WHERE id = ?`, [promptId]);
@@ -3,5 +3,9 @@ export declare function isLoopbackHost(host: string): boolean;
3
3
  export declare function isLoopbackAddress(address: string | undefined): boolean;
4
4
  /** A URL needs brackets around an IPv6 literal such as ::1. */
5
5
  export declare function webServerUrl(host: string, port: number): string;
6
- export declare function assertWebServerNetworkAuth(host: string, apiToken?: string, basicAuthEnabled?: boolean): void;
7
- export declare function authorizeApiRequest(req: Request, apiToken?: string): Response | null;
6
+ export declare function assertWebServerNetworkAuth(host: string, tokenAvailable?: boolean, basicAuthEnabled?: boolean): void;
7
+ /**
8
+ * Authorise a request by an API token from the token table, sent as a bearer
9
+ * token or in the OMMS token header. `webServerApiToken` is not read here.
10
+ */
11
+ export declare function authorizeApiRequest(req: Request, verify?: (value: string) => boolean): Response | null;
@@ -1,3 +1,4 @@
1
+ import { verifyApiToken } from "./api-tokens.js";
1
2
  import { getRequestToken } from "./auth-token.js";
2
3
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0:0:0:0:0:1"]);
3
4
  export function isLoopbackHost(host) {
@@ -18,19 +19,20 @@ export function webServerUrl(host, port) {
18
19
  const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
19
20
  return `http://${urlHost}:${port}`;
20
21
  }
21
- export function assertWebServerNetworkAuth(host, apiToken, basicAuthEnabled = false) {
22
- if (!isLoopbackHost(host) && !apiToken && !basicAuthEnabled) {
23
- throw new Error(`webServerHost "${host}" exposes the API on the network. Set webServerApiToken in omms.jsonc, or bind to 127.0.0.1.`);
22
+ export function assertWebServerNetworkAuth(host, tokenAvailable = false, basicAuthEnabled = false) {
23
+ if (!isLoopbackHost(host) && !tokenAvailable && !basicAuthEnabled) {
24
+ throw new Error(`webServerHost "${host}" exposes the API on the network. Create an API token on the Settings page, set a browser password, or bind to 127.0.0.1.`);
24
25
  }
25
26
  }
26
- export function authorizeApiRequest(req, apiToken) {
27
- if (!apiToken)
28
- return null;
27
+ /**
28
+ * Authorise a request by an API token from the token table, sent as a bearer
29
+ * token or in the OMMS token header. `webServerApiToken` is not read here.
30
+ */
31
+ export function authorizeApiRequest(req, verify = verifyApiToken) {
29
32
  const header = req.headers.get("authorization");
30
33
  const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
31
- const alt = getRequestToken(req);
32
- const token = bearer || alt;
33
- if (token && token === apiToken) {
34
+ const token = bearer || getRequestToken(req);
35
+ if (token && verify(token)) {
34
36
  return null;
35
37
  }
36
38
  return new Response(JSON.stringify({ success: false, error: "Unauthorized" }), {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Save the Basic Auth password to a private key file and point
3
+ * `webServerAuthPassword` at it. The password is never returned or logged.
4
+ */
5
+ export declare function saveWebPassword(password: string, username: string | undefined, revision: string): Promise<{
6
+ revision: string;
7
+ }>;
8
+ /** Remove both config keys and the key file the page wrote; a user's own file is left alone. */
9
+ export declare function clearWebPassword(revision: string): Promise<{
10
+ revision: string;
11
+ }>;
@@ -0,0 +1,30 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PASSWORD_KEYS, writeGlobalConfigKeys } from "./global-config-writer.js";
4
+ import { secretsDirectory, storeMemoryKeySource } from "./memory-key-source.js";
5
+ /** The page writes the browser password here and nowhere else. */
6
+ const KEY_NAME = "web-password";
7
+ /**
8
+ * Save the Basic Auth password to a private key file and point
9
+ * `webServerAuthPassword` at it. The password is never returned or logged.
10
+ */
11
+ export async function saveWebPassword(password, username, revision) {
12
+ if (!password.trim())
13
+ throw new Error("Enter a password");
14
+ const stored = await storeMemoryKeySource({
15
+ source: "paste",
16
+ name: KEY_NAME,
17
+ value: password,
18
+ replace: true,
19
+ });
20
+ return writeGlobalConfigKeys({
21
+ webServerAuthPassword: stored.reference,
22
+ webServerAuthUsername: username?.trim() || undefined,
23
+ }, revision, { only: PASSWORD_KEYS });
24
+ }
25
+ /** Remove both config keys and the key file the page wrote; a user's own file is left alone. */
26
+ export async function clearWebPassword(revision) {
27
+ const result = await writeGlobalConfigKeys({ webServerAuthPassword: undefined, webServerAuthUsername: undefined }, revision, { only: PASSWORD_KEYS });
28
+ await fs.rm(join(secretsDirectory(), `${KEY_NAME}.key`), { force: true });
29
+ return result;
30
+ }
@@ -60,6 +60,14 @@ export declare class WebServer {
60
60
  setOnPowerAction(callback: (action: PowerAction) => void | Promise<void>): void;
61
61
  /** Only a local caller may stop the web app; the token alone is not enough. */
62
62
  private handlePowerAction;
63
+ /** API token routes. Only a caller on this machine with the local token manages tokens. */
64
+ private handleTokenRoute;
65
+ /** Profile catch-up: a preview for anyone with API access; runs only from this machine. */
66
+ private handleCatchUpRoute;
67
+ /** Set or clear the browser password. The body holds the password: never log or echo it. */
68
+ private handleWebPassword;
69
+ /** Embedding card routes. Changing the embedder needs a caller on this machine with the local token. */
70
+ private handleEmbeddingRoute;
63
71
  private scheduleStepAside;
64
72
  private handleStepAside;
65
73
  start(): Promise<void>;