@kyleparrott/where-was-i 0.1.0

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.
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { loadWhereWasIConfig, mergeEmbeddingConfig } from "./core/config.js";
6
+ import { runDoctor } from "./core/doctor.js";
7
+ import { indexCodexSessions } from "./core/indexer.js";
8
+ import { resetIndexFiles } from "./core/reset.js";
9
+ import { resolveConfiguredSearchMode } from "./core/search-mode.js";
10
+ import { groupSearchResultsBySession, readMessageContext, readSession, readTurn, resolveMessageLocator, searchSessions } from "./core/search.js";
11
+ import { clearSemanticEmbeddings, indexSemanticChunks } from "./core/semantic.js";
12
+ const appConfig = loadWhereWasIConfig();
13
+ const startupIndexState = {
14
+ lexical: startupTaskState(appConfig.startup.lexical),
15
+ semantic: startupTaskState(appConfig.startup.semantic)
16
+ };
17
+ const server = new McpServer({
18
+ name: "where-was-i",
19
+ version: "0.1.0"
20
+ });
21
+ server.registerTool("search_agent_sessions", {
22
+ title: "Search Agent Sessions",
23
+ description: "Find relevant indexed agent sessions from a natural-language clue. Results are grouped by session and include message IDs plus openable locators.",
24
+ inputSchema: {
25
+ query: z.string().min(1).describe("Natural-language clue for the session or message to find."),
26
+ mode: z
27
+ .enum(["auto", "fts", "semantic", "hybrid"])
28
+ .optional()
29
+ .describe("Search strategy. auto uses hybrid when semantic vectors are usable, otherwise fts."),
30
+ limit: z.number().int().min(1).max(50).optional().describe("Maximum grouped sessions to return.")
31
+ },
32
+ outputSchema: {
33
+ requestedMode: z.enum(["auto", "fts", "semantic", "hybrid"]),
34
+ mode: z.enum(["fts", "semantic", "hybrid"]),
35
+ results: z.array(z.unknown())
36
+ }
37
+ }, async ({ query, mode, limit }) => {
38
+ const requestedMode = mode ?? "auto";
39
+ const selectedMode = await resolveConfiguredSearchMode({
40
+ dbPath: appConfig.dbPath,
41
+ requestedMode,
42
+ embedding: mergeEmbeddingConfig(appConfig)
43
+ });
44
+ const results = await searchSessions({
45
+ dbPath: appConfig.dbPath,
46
+ query,
47
+ limit,
48
+ mode: selectedMode,
49
+ embedding: mergeEmbeddingConfig(appConfig)
50
+ });
51
+ return structuredResult({
52
+ requestedMode,
53
+ mode: selectedMode,
54
+ results: compactGroupedResults(results)
55
+ });
56
+ });
57
+ server.registerTool("read_agent_session", {
58
+ title: "Read Agent Session",
59
+ description: "Read indexed messages for a specific local session.",
60
+ inputSchema: {
61
+ sessionId: z.string().min(1).describe("Session ID returned by search_agent_sessions."),
62
+ limit: z.number().int().min(1).max(500).optional().describe("Maximum messages to return."),
63
+ offset: z.number().int().min(0).optional().describe("Number of session messages to skip before reading.")
64
+ },
65
+ outputSchema: {
66
+ sessionId: z.string(),
67
+ messages: z.array(z.unknown())
68
+ }
69
+ }, async ({ sessionId, limit, offset }) => {
70
+ const messages = readSession(appConfig.dbPath, sessionId, limit, offset);
71
+ return structuredResult({ sessionId, messages });
72
+ });
73
+ server.registerTool("read_agent_message_context", {
74
+ title: "Read Agent Message Context",
75
+ description: "Read indexed messages around a specific search-hit message.",
76
+ inputSchema: {
77
+ messageId: z.string().min(1).describe("Message ID returned by search_agent_sessions."),
78
+ before: z.number().int().min(0).max(20).optional().describe("Messages before the target message."),
79
+ after: z.number().int().min(0).max(20).optional().describe("Messages after the target message.")
80
+ },
81
+ outputSchema: {
82
+ messageId: z.string(),
83
+ context: z.unknown()
84
+ }
85
+ }, async ({ messageId, before, after }) => {
86
+ const context = readMessageContext(appConfig.dbPath, messageId, before, after);
87
+ return structuredResult({ messageId, context });
88
+ });
89
+ server.registerTool("read_agent_turn", {
90
+ title: "Read Agent Turn",
91
+ description: "Read all indexed messages in a single turn.",
92
+ inputSchema: {
93
+ turnId: z.string().min(1).describe("Turn ID returned by search_agent_sessions."),
94
+ limit: z.number().int().min(1).max(100).optional().describe("Maximum turn messages to return.")
95
+ },
96
+ outputSchema: {
97
+ turnId: z.string(),
98
+ messages: z.array(z.unknown())
99
+ }
100
+ }, async ({ turnId, limit }) => {
101
+ const messages = readTurn(appConfig.dbPath, turnId, limit);
102
+ return structuredResult({ turnId, messages });
103
+ });
104
+ server.registerTool("resolve_agent_session_message", {
105
+ title: "Resolve Agent Session Message",
106
+ description: "Resolve a message ID to durable message locators and openable fallback links.",
107
+ inputSchema: {
108
+ messageId: z.string().min(1).describe("Message ID returned by search_agent_sessions.")
109
+ },
110
+ outputSchema: {
111
+ message: z.unknown()
112
+ }
113
+ }, async ({ messageId }) => {
114
+ const resolved = resolveMessageLocator(appConfig.dbPath, messageId);
115
+ if (!resolved) {
116
+ throw new Error(`Message not found: ${messageId}`);
117
+ }
118
+ return structuredResult({ message: resolved });
119
+ });
120
+ server.registerTool("index_agent_sessions", {
121
+ title: "Index Agent Sessions",
122
+ description: "Refresh the local lexical Codex session index. Semantic embeddings are indexed by index_agent_session_embeddings.",
123
+ inputSchema: {
124
+ includeArchived: z.boolean().optional().describe("Include archived Codex sessions."),
125
+ recent: z.number().int().min(1).optional().describe("Only index the N most recently changed session files."),
126
+ sessionIds: z.array(z.string().min(1)).optional().describe("Only index specific session IDs."),
127
+ force: z.boolean().optional().describe("Reindex files even when size and mtime are unchanged.")
128
+ },
129
+ outputSchema: {
130
+ lexical: z.unknown()
131
+ }
132
+ }, async ({ includeArchived, recent, sessionIds, force }) => {
133
+ const lexical = indexCodexSessions({
134
+ dbPath: appConfig.dbPath,
135
+ codexHome: appConfig.codexHome,
136
+ includeArchived,
137
+ recent,
138
+ sessionIds,
139
+ force
140
+ });
141
+ return structuredResult({ lexical });
142
+ });
143
+ server.registerTool("index_agent_session_embeddings", {
144
+ title: "Index Agent Session Embeddings",
145
+ description: "Refresh semantic embeddings for indexed user and assistant messages using the configured embedding provider.",
146
+ inputSchema: {
147
+ maxChunks: z.number().int().min(1).optional().describe("Maximum missing semantic chunks to embed this run."),
148
+ sessionIds: z.array(z.string().min(1)).optional().describe("Only embed chunks for specific session IDs.")
149
+ },
150
+ outputSchema: {
151
+ semantic: z.unknown()
152
+ }
153
+ }, async ({ maxChunks, sessionIds }) => {
154
+ const semantic = await indexSemanticChunks({
155
+ dbPath: appConfig.dbPath,
156
+ embedding: mergeEmbeddingConfig(appConfig),
157
+ maxChunks,
158
+ sessionIds
159
+ });
160
+ return structuredResult({ semantic });
161
+ });
162
+ server.registerTool("clear_agent_session_embeddings", {
163
+ title: "Clear Agent Session Embeddings",
164
+ description: "Delete all derived semantic vectors and embedding provider metadata while keeping lexical index data.",
165
+ inputSchema: {
166
+ confirm: z.literal("clear").describe("Must be exactly clear to confirm deletion.")
167
+ },
168
+ outputSchema: {
169
+ summary: z.unknown()
170
+ }
171
+ }, async ({ confirm }) => {
172
+ if (confirm !== "clear") {
173
+ throw new Error('Set confirm to "clear" to delete derived embedding vectors.');
174
+ }
175
+ const summary = clearSemanticEmbeddings(appConfig.dbPath);
176
+ return structuredResult({ summary });
177
+ });
178
+ server.registerTool("reset_agent_session_index", {
179
+ title: "Reset Agent Session Index",
180
+ description: "Delete the entire derived local SQLite index, including lexical and semantic stores, without touching Codex transcripts.",
181
+ inputSchema: {
182
+ confirm: z.literal("reset").describe("Must be exactly reset to confirm deletion.")
183
+ },
184
+ outputSchema: {
185
+ summary: z.unknown()
186
+ }
187
+ }, async ({ confirm }) => {
188
+ if (confirm !== "reset") {
189
+ throw new Error('Set confirm to "reset" to delete the derived local index.');
190
+ }
191
+ const summary = resetIndexFiles(appConfig.dbPath);
192
+ return structuredResult({ summary });
193
+ });
194
+ server.registerTool("agent_session_status", {
195
+ title: "Agent Session Index Status",
196
+ description: "Show whether indexed session search is ready, plus path, index, semantic, and startup details.",
197
+ inputSchema: {},
198
+ outputSchema: {
199
+ readyForSearch: z.boolean(),
200
+ recommendedSearchMode: z.enum(["fts", "hybrid"]),
201
+ blockingIssues: z.array(z.string()),
202
+ semanticWarnings: z.array(z.string()),
203
+ paths: z.unknown(),
204
+ index: z.unknown(),
205
+ embedding: z.unknown(),
206
+ semantic: z.unknown(),
207
+ startupIndex: z.unknown()
208
+ }
209
+ }, async () => {
210
+ const report = await runDoctor({
211
+ dbPath: appConfig.dbPath,
212
+ codexHome: appConfig.codexHome,
213
+ embedding: mergeEmbeddingConfig(appConfig)
214
+ });
215
+ const status = agentStatusFromDoctor(report);
216
+ return structuredResult({ ...status, startupIndex: startupIndexState });
217
+ });
218
+ function agentStatusFromDoctor(report) {
219
+ const blockingIssues = [];
220
+ if (!report.paths.codexHomeExists) {
221
+ blockingIssues.push(`Codex home does not exist: ${report.paths.codexHome}`);
222
+ }
223
+ if (!report.paths.sessionsDirExists) {
224
+ blockingIssues.push(`Codex sessions directory does not exist: ${report.paths.sessionsDir}`);
225
+ }
226
+ if (report.index.messages === 0) {
227
+ blockingIssues.push("Run index_agent_sessions to build the lexical message index.");
228
+ }
229
+ const semanticWarnings = [];
230
+ const semanticConfigured = Boolean(report.embedding.baseUrl && report.embedding.model);
231
+ if (semanticConfigured && !report.embedding.available) {
232
+ semanticWarnings.push(`Embedding provider unavailable: ${report.embedding.error}`);
233
+ }
234
+ if (semanticConfigured && report.semantic.missingChunks !== null && report.semantic.missingChunks > 0) {
235
+ semanticWarnings.push(mcpSemanticRecommendation(report.semantic.recommendation));
236
+ }
237
+ if (semanticConfigured && report.semantic.incompatibleStoredVectors > 0) {
238
+ semanticWarnings.push("Stored semantic vectors are incompatible with the configured embedding provider.");
239
+ }
240
+ const semanticUsable = semanticConfigured && report.semantic.indexedChunks > 0 && report.semantic.incompatibleStoredVectors === 0;
241
+ return {
242
+ readyForSearch: blockingIssues.length === 0,
243
+ recommendedSearchMode: semanticUsable ? "hybrid" : "fts",
244
+ blockingIssues,
245
+ semanticWarnings,
246
+ paths: report.paths,
247
+ index: report.index,
248
+ embedding: report.embedding,
249
+ semantic: report.semantic
250
+ };
251
+ }
252
+ function mcpSemanticRecommendation(recommendation) {
253
+ if (!recommendation) {
254
+ return "Call index_agent_session_embeddings to refresh semantic vectors.";
255
+ }
256
+ return recommendation
257
+ .replace(/Run `wwi index --semantic`/g, "Call index_agent_session_embeddings")
258
+ .replace(/Run `wwi embeddings clear`/g, "Call clear_agent_session_embeddings");
259
+ }
260
+ function structuredResult(value) {
261
+ return {
262
+ structuredContent: value,
263
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
264
+ };
265
+ }
266
+ function compactGroupedResults(results) {
267
+ return groupSearchResultsBySession(results).map((group) => ({
268
+ session: group.session,
269
+ bestMessage: group.bestMessage,
270
+ match: group.match,
271
+ score: group.score,
272
+ hits: group.hits.map((hit) => ({
273
+ messageId: hit.messageId,
274
+ sessionId: hit.sessionId,
275
+ conversationId: hit.conversationId,
276
+ turnId: hit.turnId,
277
+ role: hit.role,
278
+ timestamp: hit.timestamp,
279
+ preview: hit.preview,
280
+ locator: hit.locator,
281
+ links: hit.links,
282
+ match: hit.match,
283
+ scores: hit.scores
284
+ }))
285
+ }));
286
+ }
287
+ const transport = new StdioServerTransport();
288
+ if (hasBlockingStartup()) {
289
+ await startStartupIndex();
290
+ }
291
+ await server.connect(transport);
292
+ if (!hasBlockingStartup()) {
293
+ void startStartupIndex();
294
+ }
295
+ async function startStartupIndex() {
296
+ const blockingTasks = [];
297
+ if (appConfig.startup.lexical !== "off") {
298
+ const task = runStartupTask(startupIndexState.lexical, () => indexCodexSessions({
299
+ dbPath: appConfig.dbPath,
300
+ codexHome: appConfig.codexHome
301
+ }));
302
+ if (appConfig.startup.lexical === "blocking") {
303
+ blockingTasks.push(task);
304
+ }
305
+ }
306
+ if (appConfig.startup.semantic !== "off") {
307
+ const task = runStartupTask(startupIndexState.semantic, () => indexSemanticChunks({
308
+ dbPath: appConfig.dbPath,
309
+ embedding: appConfig.semantic.embedding,
310
+ maxChunks: appConfig.semantic.startupMaxChunks ?? undefined
311
+ }));
312
+ if (appConfig.startup.semantic === "blocking") {
313
+ blockingTasks.push(task);
314
+ }
315
+ }
316
+ await Promise.all(blockingTasks);
317
+ }
318
+ function hasBlockingStartup() {
319
+ return appConfig.startup.lexical === "blocking" || appConfig.startup.semantic === "blocking";
320
+ }
321
+ function startupTaskState(mode) {
322
+ return {
323
+ mode,
324
+ status: mode === "off" ? "off" : "queued",
325
+ summary: null,
326
+ startedAt: null,
327
+ completedAt: null,
328
+ error: null
329
+ };
330
+ }
331
+ async function runStartupTask(state, task) {
332
+ state.status = "running";
333
+ state.startedAt = new Date().toISOString();
334
+ try {
335
+ state.summary = await task();
336
+ state.status = "complete";
337
+ }
338
+ catch (error) {
339
+ state.error = error instanceof Error ? error.message : String(error);
340
+ state.status = "failed";
341
+ }
342
+ finally {
343
+ state.completedAt = new Date().toISOString();
344
+ }
345
+ }
@@ -0,0 +1,61 @@
1
+ export const WEB_CLIENT_SCRIPT = `
2
+ (() => {
3
+ const panel = document.querySelector("[data-embedding-job-id]");
4
+ if (!panel) {
5
+ return;
6
+ }
7
+ const jobId = panel.getAttribute("data-embedding-job-id");
8
+ const jobStatus = panel.getAttribute("data-embedding-job-status");
9
+ if (!jobId || jobStatus !== "running") {
10
+ return;
11
+ }
12
+
13
+ const statusSlot = document.getElementById("embedding-status");
14
+ const jobSlot = document.getElementById("embedding-job-slot");
15
+ const submitButton = document.getElementById("embedding-submit");
16
+ let failures = 0;
17
+
18
+ async function poll() {
19
+ try {
20
+ const response = await fetch("/embedding-jobs/" + encodeURIComponent(jobId), {
21
+ headers: { accept: "application/json" }
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error("Embedding job status failed.");
25
+ }
26
+ const update = await response.json();
27
+ if (statusSlot && typeof update.statusHtml === "string") {
28
+ statusSlot.innerHTML = update.statusHtml;
29
+ }
30
+ if (jobSlot && typeof update.jobHtml === "string") {
31
+ jobSlot.innerHTML = update.jobHtml;
32
+ }
33
+ if (submitButton) {
34
+ submitButton.disabled = Boolean(update.buttonDisabled);
35
+ if (typeof update.buttonText === "string") {
36
+ submitButton.textContent = update.buttonText;
37
+ }
38
+ }
39
+ panel.setAttribute("data-embedding-job-status", update.job?.status ?? "complete");
40
+ failures = 0;
41
+ if (update.done) {
42
+ return;
43
+ }
44
+ } catch {
45
+ failures += 1;
46
+ if (failures >= 5) {
47
+ if (jobSlot) {
48
+ jobSlot.insertAdjacentHTML(
49
+ "beforeend",
50
+ '<p class="progress-error">Live progress paused. Refresh to check the latest status.</p>'
51
+ );
52
+ }
53
+ return;
54
+ }
55
+ }
56
+ window.setTimeout(poll, 1000);
57
+ }
58
+
59
+ window.setTimeout(poll, 250);
60
+ })();
61
+ `;
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { defaultConfigTemplate, validateWhereWasIConfigObject } from "./core/config.js";
4
+ export const STARTUP_MODES = ["off", "background", "blocking"];
5
+ export function settingsViewModel(configPath, saved) {
6
+ const { raw, exists } = readRawConfig(configPath);
7
+ const semantic = asRecord(raw.semantic);
8
+ const provider = asRecord(semantic.provider ?? semantic);
9
+ const startup = asRecord(raw.startup);
10
+ const apiKey = stringValue(provider.apiKey);
11
+ return {
12
+ configPath,
13
+ exists,
14
+ saved,
15
+ errors: [],
16
+ values: {
17
+ dbPath: stringValue(raw.dbPath) ?? "~/.where-was-i/index.sqlite",
18
+ codexHome: stringValue(raw.codexHome) ?? "~/.codex",
19
+ startupLexical: startupModeValue(startup.lexical, "off"),
20
+ startupSemantic: startupModeValue(startup.semantic, "off"),
21
+ semanticStartupMaxChunks: semantic.startupMaxChunks === null ? "" : stringValue(semantic.startupMaxChunks) ?? "100",
22
+ embeddingBaseUrl: stringValue(provider.baseUrl) ?? "",
23
+ embeddingModel: stringValue(provider.model) ?? "",
24
+ embeddingApiKeyEnv: stringValue(provider.apiKeyEnv) ?? "WHERE_WAS_I_EMBEDDING_API_KEY",
25
+ embeddingApiKey: "",
26
+ embeddingApiKeyStored: Boolean(apiKey),
27
+ embeddingApiKeyClear: false,
28
+ embeddingTimeoutMs: stringValue(provider.timeoutMs) ?? ""
29
+ }
30
+ };
31
+ }
32
+ export function saveSettings(configPath, params) {
33
+ const current = settingsViewModel(configPath, false).values;
34
+ const values = {
35
+ dbPath: formValue(params, "dbPath"),
36
+ codexHome: formValue(params, "codexHome"),
37
+ startupLexical: startupModeValue(params.get("startupLexical"), "off"),
38
+ startupSemantic: startupModeValue(params.get("startupSemantic"), "off"),
39
+ semanticStartupMaxChunks: formValue(params, "semanticStartupMaxChunks"),
40
+ embeddingBaseUrl: formValue(params, "embeddingBaseUrl"),
41
+ embeddingModel: formValue(params, "embeddingModel"),
42
+ embeddingApiKeyEnv: formValue(params, "embeddingApiKeyEnv"),
43
+ embeddingApiKey: formValue(params, "embeddingApiKey"),
44
+ embeddingApiKeyStored: current.embeddingApiKeyStored,
45
+ embeddingApiKeyClear: params.get("embeddingApiKeyClear") === "on",
46
+ embeddingTimeoutMs: formValue(params, "embeddingTimeoutMs")
47
+ };
48
+ const errors = [];
49
+ if (!values.dbPath)
50
+ errors.push("Index path is required.");
51
+ if (!values.codexHome)
52
+ errors.push("Codex home is required.");
53
+ if (!STARTUP_MODES.includes(values.startupLexical))
54
+ errors.push("Lexical startup mode is invalid.");
55
+ if (!STARTUP_MODES.includes(values.startupSemantic))
56
+ errors.push("Semantic startup mode is invalid.");
57
+ const startupMaxChunks = optionalPositiveInteger(values.semanticStartupMaxChunks, "Semantic startup chunk cap", errors, true);
58
+ const timeoutMs = optionalPositiveInteger(values.embeddingTimeoutMs, "Timeout ms", errors, false);
59
+ if (errors.length > 0) {
60
+ return { values, errors };
61
+ }
62
+ const { raw } = readRawConfig(configPath);
63
+ const next = { ...raw };
64
+ next.dbPath = values.dbPath;
65
+ next.codexHome = values.codexHome;
66
+ next.startup = {
67
+ ...asRecord(raw.startup),
68
+ lexical: values.startupLexical,
69
+ semantic: values.startupSemantic
70
+ };
71
+ const rawSemantic = asRecord(raw.semantic);
72
+ const rawProvider = asRecord(rawSemantic.provider ?? rawSemantic);
73
+ const provider = { ...rawProvider };
74
+ setOptionalString(provider, "baseUrl", values.embeddingBaseUrl);
75
+ setOptionalString(provider, "model", values.embeddingModel);
76
+ setOptionalString(provider, "apiKeyEnv", values.embeddingApiKeyEnv);
77
+ if (values.embeddingApiKeyClear) {
78
+ delete provider.apiKey;
79
+ values.embeddingApiKeyStored = false;
80
+ }
81
+ else if (values.embeddingApiKey) {
82
+ provider.apiKey = values.embeddingApiKey;
83
+ values.embeddingApiKeyStored = true;
84
+ }
85
+ if (timeoutMs === undefined) {
86
+ delete provider.timeoutMs;
87
+ }
88
+ else {
89
+ provider.timeoutMs = timeoutMs;
90
+ }
91
+ const semantic = { ...rawSemantic, startupMaxChunks, provider };
92
+ for (const key of ["baseUrl", "model", "apiKey", "apiKeyEnv", "timeoutMs"]) {
93
+ delete semantic[key];
94
+ }
95
+ next.semantic = semantic;
96
+ try {
97
+ validateWhereWasIConfigObject(next, configPath);
98
+ writeConfigAtomically(configPath, next);
99
+ }
100
+ catch (error) {
101
+ errors.push(error instanceof Error ? error.message : String(error));
102
+ }
103
+ return { values: { ...values, embeddingApiKey: "" }, errors };
104
+ }
105
+ function readRawConfig(configPath) {
106
+ if (!fs.existsSync(configPath)) {
107
+ return { raw: defaultConfigTemplate(), exists: false };
108
+ }
109
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
110
+ validateWhereWasIConfigObject(parsed, configPath);
111
+ return { raw: parsed, exists: true };
112
+ }
113
+ function writeConfigAtomically(configPath, value) {
114
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
115
+ const tempPath = path.join(path.dirname(configPath), `.${path.basename(configPath)}.${process.pid}.${Date.now()}.tmp`);
116
+ fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
117
+ fs.renameSync(tempPath, configPath);
118
+ fs.chmodSync(configPath, 0o600);
119
+ }
120
+ function optionalPositiveInteger(value, label, errors, allowOff) {
121
+ const trimmed = value.trim();
122
+ if (!trimmed || (allowOff && trimmed.toLowerCase() === "off")) {
123
+ return allowOff ? null : undefined;
124
+ }
125
+ const parsed = Number(trimmed);
126
+ if (!Number.isInteger(parsed) || parsed < 1) {
127
+ errors.push(`${label} must be a positive integer${allowOff ? " or blank" : ""}.`);
128
+ return undefined;
129
+ }
130
+ return parsed;
131
+ }
132
+ function startupModeValue(value, fallback) {
133
+ return value === "off" || value === "background" || value === "blocking" ? value : fallback;
134
+ }
135
+ function setOptionalString(target, key, value) {
136
+ if (value) {
137
+ target[key] = value;
138
+ }
139
+ else {
140
+ delete target[key];
141
+ }
142
+ }
143
+ function formValue(params, key) {
144
+ return (params.get(key) ?? "").trim();
145
+ }
146
+ function stringValue(value) {
147
+ if (typeof value === "string") {
148
+ return value;
149
+ }
150
+ if (typeof value === "number") {
151
+ return String(value);
152
+ }
153
+ return undefined;
154
+ }
155
+ function asRecord(value) {
156
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
157
+ }