@codemem/server 0.0.0 → 0.20.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1383 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CODEMEM_CONFIG_ENV_OVERRIDES, MemoryStore, VERSION, buildRawEventEnvelopeFromHook, ensureDeviceIdentity, fromJson, getCodememConfigPath, getCodememEnvOverrides, parseStrictInteger, readCodememConfigFile, resolveDbPath, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, writeCodememConfigFile } from "@codemem/core";
4
+ import { serveStatic } from "@hono/node-server/serve-static";
5
+ import { Hono } from "hono";
6
+ import { createMiddleware } from "hono/factory";
7
+ import { homedir } from "node:os";
8
+ import { count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
9
+ import { drizzle } from "drizzle-orm/better-sqlite3";
10
+ import { createHash } from "node:crypto";
11
+ //#region src/middleware.ts
12
+ var LOOPBACK_HOSTS = new Set([
13
+ "127.0.0.1",
14
+ "localhost",
15
+ "::1"
16
+ ]);
17
+ /**
18
+ * Check whether an Origin header value is a valid loopback URL.
19
+ * Mirrors Python's _is_allowed_loopback_origin_url().
20
+ */
21
+ function isLoopbackOrigin(origin) {
22
+ let url;
23
+ try {
24
+ url = new URL(origin);
25
+ } catch {
26
+ return false;
27
+ }
28
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
29
+ if (url.username || url.password) return false;
30
+ return LOOPBACK_HOSTS.has(url.hostname);
31
+ }
32
+ /** HTTP methods that mutate state and require origin validation. */
33
+ var UNSAFE_METHODS = new Set([
34
+ "POST",
35
+ "DELETE",
36
+ "PATCH",
37
+ "PUT"
38
+ ]);
39
+ /**
40
+ * Check whether a missing-Origin request looks like a cross-site browser
41
+ * request. Matches Python's `_is_unsafe_missing_origin()`:
42
+ *
43
+ * - Sec-Fetch-Site present and NOT same-origin/same-site/none → unsafe
44
+ * - Referer present and NOT loopback → unsafe
45
+ * - Otherwise → safe (CLI / programmatic caller, no browser context)
46
+ */
47
+ function isUnsafeMissingOrigin(c) {
48
+ const secFetchSite = (c.req.header("Sec-Fetch-Site") ?? "").trim().toLowerCase();
49
+ if (secFetchSite && ![
50
+ "same-origin",
51
+ "same-site",
52
+ "none"
53
+ ].includes(secFetchSite)) return true;
54
+ const referer = c.req.header("Referer");
55
+ if (!referer) return false;
56
+ return !isLoopbackOrigin(referer);
57
+ }
58
+ /**
59
+ * Cross-origin protection middleware.
60
+ *
61
+ * Ports Python's `reject_cross_origin(missing_origin_policy="reject_if_unsafe")`:
62
+ *
63
+ * - GET/HEAD/OPTIONS: allowed from any origin (viewer is local-only).
64
+ * - POST/DELETE/PATCH/PUT:
65
+ * - Origin present + loopback → allowed (browser on localhost)
66
+ * - Origin present + non-loopback → rejected 403
67
+ * - No Origin + no suspicious browser signals → allowed (CLI callers)
68
+ * - No Origin + suspicious Sec-Fetch-Site/Referer → rejected 403
69
+ *
70
+ * For same-origin requests (no Origin header) on safe methods, no
71
+ * Access-Control-Allow-Origin is set — the browser doesn't need it.
72
+ * For valid loopback origins, ACAO is echoed back.
73
+ */
74
+ function originGuard() {
75
+ return createMiddleware(async (c, next) => {
76
+ const origin = c.req.header("Origin");
77
+ const method = c.req.method;
78
+ if (UNSAFE_METHODS.has(method)) {
79
+ if (origin) {
80
+ if (!isLoopbackOrigin(origin)) return c.json({ error: "forbidden" }, 403);
81
+ c.header("Access-Control-Allow-Origin", origin);
82
+ c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
83
+ c.header("Access-Control-Allow-Headers", "Content-Type");
84
+ } else if (isUnsafeMissingOrigin(c)) return c.json({ error: "forbidden" }, 403);
85
+ } else if (origin && isLoopbackOrigin(origin)) {
86
+ c.header("Access-Control-Allow-Origin", origin);
87
+ c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
88
+ c.header("Access-Control-Allow-Headers", "Content-Type");
89
+ }
90
+ await next();
91
+ });
92
+ }
93
+ /**
94
+ * Handle OPTIONS preflight requests.
95
+ * Returns 204 with appropriate CORS headers for loopback origins.
96
+ */
97
+ function preflightHandler() {
98
+ return createMiddleware(async (c, next) => {
99
+ if (c.req.method !== "OPTIONS") {
100
+ await next();
101
+ return;
102
+ }
103
+ const origin = c.req.header("Origin");
104
+ if (origin && isLoopbackOrigin(origin)) {
105
+ c.header("Access-Control-Allow-Origin", origin);
106
+ c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
107
+ c.header("Access-Control-Allow-Headers", "Content-Type");
108
+ c.header("Access-Control-Max-Age", "86400");
109
+ return c.body(null, 204);
110
+ }
111
+ return c.body(null, 204);
112
+ });
113
+ }
114
+ //#endregion
115
+ //#region src/routes/config.ts
116
+ /**
117
+ * Config routes — GET /api/config, POST /api/config.
118
+ *
119
+ * Ports the user-facing config read/write path from Python's
120
+ * codemem/viewer_routes/config.py, scoped to the TS runtime's current needs.
121
+ */
122
+ var RUNTIMES = new Set(["api_http", "claude_sidecar"]);
123
+ var AUTH_SOURCES = new Set([
124
+ "auto",
125
+ "env",
126
+ "file",
127
+ "command",
128
+ "none"
129
+ ]);
130
+ var HOT_RELOAD_KEYS = new Set(["raw_events_sweeper_interval_s"]);
131
+ var ALLOWED_KEYS = [
132
+ "claude_command",
133
+ "observer_base_url",
134
+ "observer_provider",
135
+ "observer_model",
136
+ "observer_runtime",
137
+ "observer_auth_source",
138
+ "observer_auth_file",
139
+ "observer_auth_command",
140
+ "observer_auth_timeout_ms",
141
+ "observer_auth_cache_ttl_s",
142
+ "observer_headers",
143
+ "observer_max_chars",
144
+ "pack_observation_limit",
145
+ "pack_session_limit",
146
+ "sync_enabled",
147
+ "sync_host",
148
+ "sync_port",
149
+ "sync_interval_s",
150
+ "sync_mdns",
151
+ "sync_coordinator_url",
152
+ "sync_coordinator_group",
153
+ "sync_coordinator_timeout_s",
154
+ "sync_coordinator_presence_ttl_s",
155
+ "raw_events_sweeper_interval_s"
156
+ ];
157
+ var DEFAULTS = {
158
+ claude_command: ["claude"],
159
+ observer_runtime: "api_http",
160
+ observer_auth_source: "auto",
161
+ observer_auth_command: [],
162
+ observer_auth_timeout_ms: 1500,
163
+ observer_auth_cache_ttl_s: 300,
164
+ observer_headers: {},
165
+ observer_max_chars: 12e3,
166
+ pack_observation_limit: 50,
167
+ pack_session_limit: 10,
168
+ sync_enabled: false,
169
+ sync_host: "0.0.0.0",
170
+ sync_port: 7337,
171
+ sync_interval_s: 120,
172
+ sync_mdns: true,
173
+ sync_coordinator_timeout_s: 3,
174
+ sync_coordinator_presence_ttl_s: 180,
175
+ raw_events_sweeper_interval_s: 30
176
+ };
177
+ function loadProviderOptions() {
178
+ return [
179
+ "openai",
180
+ "anthropic",
181
+ "google",
182
+ "xai",
183
+ "groq",
184
+ "deepseek",
185
+ "mistral",
186
+ "together"
187
+ ];
188
+ }
189
+ function getConfigPath() {
190
+ const envPath = process.env.CODEMEM_CONFIG;
191
+ if (envPath) return envPath.replace(/^~/, homedir());
192
+ const configDir = join(homedir(), ".config", "codemem");
193
+ return [join(configDir, "config.json"), join(configDir, "config.jsonc")].find((p) => existsSync(p)) ?? join(configDir, "config.json");
194
+ }
195
+ function readConfigFile(configPath) {
196
+ if (!existsSync(configPath)) return {};
197
+ try {
198
+ let text = readFileSync(configPath, "utf-8").trim();
199
+ if (!text) return {};
200
+ try {
201
+ return JSON.parse(text);
202
+ } catch {
203
+ text = stripTrailingCommas(stripJsonComments(text));
204
+ return JSON.parse(text);
205
+ }
206
+ } catch {
207
+ return {};
208
+ }
209
+ }
210
+ function getEffectiveConfig(configData) {
211
+ const effective = {
212
+ ...DEFAULTS,
213
+ ...configData
214
+ };
215
+ for (const [key, envVar] of Object.entries(CODEMEM_CONFIG_ENV_OVERRIDES)) {
216
+ const val = process.env[envVar];
217
+ if (val != null && val !== "") effective[key] = val;
218
+ }
219
+ return effective;
220
+ }
221
+ function parsePositiveInt(value, allowZero = false) {
222
+ if (typeof value === "boolean") return null;
223
+ const parsed = typeof value === "number" ? value : typeof value === "string" && /^-?\d+$/.test(value.trim()) ? Number(value.trim()) : NaN;
224
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return null;
225
+ if (allowZero) return parsed >= 0 ? parsed : null;
226
+ return parsed > 0 ? parsed : null;
227
+ }
228
+ function asStringMap(value) {
229
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
230
+ const parsed = {};
231
+ for (const [key, item] of Object.entries(value)) {
232
+ if (typeof item !== "string") return null;
233
+ const stripped = key.trim();
234
+ if (!stripped) return null;
235
+ parsed[stripped] = item;
236
+ }
237
+ return parsed;
238
+ }
239
+ function asExecutableArgv(value) {
240
+ if (!Array.isArray(value)) return null;
241
+ const argv = [];
242
+ for (const item of value) {
243
+ if (typeof item !== "string") return null;
244
+ const token = item.trim();
245
+ if (!token) return null;
246
+ argv.push(token);
247
+ }
248
+ return argv;
249
+ }
250
+ function validateAndApplyUpdate(configData, key, value, providers) {
251
+ if (value == null || value === "") {
252
+ delete configData[key];
253
+ return null;
254
+ }
255
+ if (key === "observer_provider") {
256
+ if (typeof value !== "string") return "observer_provider must be string";
257
+ const provider = value.trim().toLowerCase();
258
+ const savedBaseUrl = configData.observer_base_url;
259
+ const hasSavedBaseUrl = typeof savedBaseUrl === "string" && savedBaseUrl.trim().length > 0;
260
+ if (!providers.has(provider) && !hasSavedBaseUrl) return "observer_provider must match a configured provider";
261
+ configData[key] = provider;
262
+ return null;
263
+ }
264
+ if (key === "observer_runtime") {
265
+ if (typeof value !== "string") return "observer_runtime must be string";
266
+ const runtime = value.trim().toLowerCase();
267
+ if (!RUNTIMES.has(runtime)) return "observer_runtime must be one of: api_http, claude_sidecar";
268
+ configData[key] = runtime;
269
+ return null;
270
+ }
271
+ if (key === "observer_auth_source") {
272
+ if (typeof value !== "string") return "observer_auth_source must be string";
273
+ const source = value.trim().toLowerCase();
274
+ if (!AUTH_SOURCES.has(source)) return "observer_auth_source must be one of: auto, env, file, command, none";
275
+ configData[key] = source;
276
+ return null;
277
+ }
278
+ if (key === "claude_command" || key === "observer_auth_command") {
279
+ const argv = asExecutableArgv(value);
280
+ if (argv == null) return `${key} must be string array`;
281
+ if (argv.length > 0) configData[key] = argv;
282
+ else delete configData[key];
283
+ return null;
284
+ }
285
+ if (key === "observer_headers") {
286
+ const headers = asStringMap(value);
287
+ if (headers == null) return "observer_headers must be object of string values";
288
+ if (Object.keys(headers).length > 0) configData[key] = headers;
289
+ else delete configData[key];
290
+ return null;
291
+ }
292
+ if (key === "sync_enabled" || key === "sync_mdns") {
293
+ if (typeof value !== "boolean") return `${key} must be boolean`;
294
+ configData[key] = value;
295
+ return null;
296
+ }
297
+ if (key === "observer_base_url" || key === "observer_model" || key === "observer_auth_file" || key === "sync_host" || key === "sync_coordinator_url" || key === "sync_coordinator_group") {
298
+ if (typeof value !== "string") return `${key} must be string`;
299
+ const trimmed = value.trim();
300
+ if (!trimmed) delete configData[key];
301
+ else configData[key] = trimmed;
302
+ return null;
303
+ }
304
+ const allowZero = key === "observer_auth_cache_ttl_s";
305
+ const parsed = parsePositiveInt(value, allowZero);
306
+ if (parsed == null) return `${key} must be ${allowZero ? "non-negative int" : "int"}`;
307
+ configData[key] = parsed;
308
+ return null;
309
+ }
310
+ function applyRuntimeEffects(changedKeys, opts) {
311
+ const applied = [];
312
+ if (changedKeys.includes("raw_events_sweeper_interval_s")) {
313
+ const configValue = readCodememConfigFile().raw_events_sweeper_interval_s;
314
+ const seconds = typeof configValue === "number" ? configValue : Number.parseInt(String(configValue ?? ""), 10);
315
+ if (Number.isFinite(seconds) && seconds > 0) process.env.CODEMEM_RAW_EVENTS_SWEEPER_INTERVAL_MS = String(seconds * 1e3);
316
+ else delete process.env.CODEMEM_RAW_EVENTS_SWEEPER_INTERVAL_MS;
317
+ opts.getSweeper?.()?.notifyConfigChanged();
318
+ applied.push("raw_events_sweeper_interval_s");
319
+ }
320
+ return applied;
321
+ }
322
+ function configRoutes(opts = {}) {
323
+ const app = new Hono();
324
+ app.get("/api/config", (c) => {
325
+ const configPath = getConfigPath();
326
+ const configData = readConfigFile(configPath);
327
+ return c.json({
328
+ path: configPath,
329
+ config: configData,
330
+ defaults: DEFAULTS,
331
+ effective: getEffectiveConfig(configData),
332
+ env_overrides: getCodememEnvOverrides(),
333
+ providers: loadProviderOptions()
334
+ });
335
+ });
336
+ app.post("/api/config", async (c) => {
337
+ let payload;
338
+ try {
339
+ payload = await c.req.json();
340
+ } catch {
341
+ return c.json({ error: "invalid json" }, 400);
342
+ }
343
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) return c.json({ error: "payload must be an object" }, 400);
344
+ if ("config" in payload && payload.config != null && (typeof payload.config !== "object" || Array.isArray(payload.config))) return c.json({ error: "config must be an object" }, 400);
345
+ const updates = "config" in payload && payload.config != null && typeof payload.config === "object" && !Array.isArray(payload.config) ? payload.config : payload;
346
+ const configPath = getCodememConfigPath();
347
+ const beforeConfig = readCodememConfigFile();
348
+ const beforeEffective = getEffectiveConfig(beforeConfig);
349
+ const nextConfig = { ...beforeConfig };
350
+ const providers = new Set(loadProviderOptions());
351
+ const touchedKeys = ALLOWED_KEYS.filter((key) => key in updates);
352
+ for (const key of ALLOWED_KEYS) {
353
+ if (!(key in updates)) continue;
354
+ const error = validateAndApplyUpdate(nextConfig, key, updates[key], providers);
355
+ if (error) return c.json({ error }, 400);
356
+ }
357
+ let savedPath;
358
+ try {
359
+ savedPath = writeCodememConfigFile(nextConfig, configPath);
360
+ } catch {
361
+ return c.json({ error: "failed to write config" }, 500);
362
+ }
363
+ const afterEffective = getEffectiveConfig(nextConfig);
364
+ const savedChangedKeys = ALLOWED_KEYS.filter((key) => beforeConfig[key] !== nextConfig[key]);
365
+ const effectiveChangedKeys = ALLOWED_KEYS.filter((key) => beforeEffective[key] !== afterEffective[key]);
366
+ const envOverrides = getCodememEnvOverrides();
367
+ const ignoredByEnvKeys = savedChangedKeys.filter((key) => !effectiveChangedKeys.includes(key) && key in envOverrides);
368
+ const hotReloadedKeys = applyRuntimeEffects([...new Set([
369
+ ...touchedKeys,
370
+ ...savedChangedKeys,
371
+ ...effectiveChangedKeys
372
+ ])], opts);
373
+ const restartRequiredKeys = effectiveChangedKeys.filter((key) => !HOT_RELOAD_KEYS.has(key) && !(key in envOverrides));
374
+ return c.json({
375
+ path: savedPath,
376
+ config: nextConfig,
377
+ effective: afterEffective,
378
+ effects: {
379
+ saved_keys: savedChangedKeys,
380
+ effective_keys: effectiveChangedKeys,
381
+ hot_reloaded_keys: hotReloadedKeys,
382
+ restart_required_keys: restartRequiredKeys,
383
+ ignored_by_env_keys: ignoredByEnvKeys,
384
+ warnings: ignoredByEnvKeys.map((key) => `${key} is currently controlled by ${envOverrides[key]}; saved config will not take effect until that override is removed.`)
385
+ }
386
+ });
387
+ });
388
+ return app;
389
+ }
390
+ //#endregion
391
+ //#region src/helpers.ts
392
+ /**
393
+ * Shared helpers for viewer-server routes.
394
+ */
395
+ /**
396
+ * Parse a JSON string that should be an array of strings.
397
+ * Returns an empty array on null, invalid JSON, or non-array values.
398
+ * Mirrors Python's store._safe_json_list().
399
+ */
400
+ function safeJsonList(raw) {
401
+ if (raw == null) return [];
402
+ try {
403
+ const parsed = JSON.parse(raw);
404
+ if (!Array.isArray(parsed)) return [];
405
+ return parsed.filter((item) => typeof item === "string");
406
+ } catch {
407
+ return [];
408
+ }
409
+ }
410
+ /**
411
+ * Parse a query parameter as an integer, returning the default on failure.
412
+ */
413
+ function queryInt(value, defaultValue) {
414
+ if (value == null) return defaultValue;
415
+ const parsed = parseStrictInteger(value);
416
+ return parsed == null ? defaultValue : parsed;
417
+ }
418
+ /**
419
+ * Parse a query parameter as a boolean flag.
420
+ * Recognizes "1", "true", "yes" as truthy.
421
+ */
422
+ function queryBool(value) {
423
+ if (value == null) return false;
424
+ return value === "1" || value === "true" || value === "yes";
425
+ }
426
+ //#endregion
427
+ //#region src/routes/memory.ts
428
+ /**
429
+ * Attach session project/cwd fields to memory items.
430
+ */
431
+ function attachSessionFields(store, items) {
432
+ const sessionIds = [];
433
+ const seen = /* @__PURE__ */ new Set();
434
+ for (const item of items) {
435
+ const value = item.session_id;
436
+ if (value == null) continue;
437
+ const sid = Number(value);
438
+ if (Number.isNaN(sid) || seen.has(sid)) continue;
439
+ seen.add(sid);
440
+ sessionIds.push(sid);
441
+ }
442
+ if (sessionIds.length === 0) return;
443
+ const rows = drizzle(store.db, { schema }).select({
444
+ id: schema.sessions.id,
445
+ project: schema.sessions.project,
446
+ cwd: schema.sessions.cwd
447
+ }).from(schema.sessions).where(inArray(schema.sessions.id, sessionIds)).all();
448
+ const bySession = /* @__PURE__ */ new Map();
449
+ for (const row of rows) {
450
+ const projectRaw = String(row.project ?? "").trim();
451
+ const project = projectRaw ? projectBasename(projectRaw) : "";
452
+ const cwd = String(row.cwd ?? "");
453
+ bySession.set(row.id, {
454
+ project,
455
+ cwd
456
+ });
457
+ }
458
+ for (const item of items) {
459
+ const sid = Number(item.session_id);
460
+ if (Number.isNaN(sid)) continue;
461
+ const fields = bySession.get(sid);
462
+ if (!fields) continue;
463
+ item.project ??= fields.project;
464
+ item.cwd ??= fields.cwd;
465
+ }
466
+ }
467
+ /**
468
+ * Extract the basename of a project path.
469
+ * Strips "fatal:" prefixed values.
470
+ */
471
+ function projectBasename(raw) {
472
+ if (raw.toLowerCase().startsWith("fatal:")) return "";
473
+ const parts = raw.replace(/\\/g, "/").split("/");
474
+ return parts[parts.length - 1] ?? raw;
475
+ }
476
+ function memoryRoutes(getStore) {
477
+ const app = new Hono();
478
+ app.get("/api/sessions", (c) => {
479
+ const store = getStore();
480
+ {
481
+ const limit = queryInt(c.req.query("limit"), 20);
482
+ const items = drizzle(store.db, { schema }).select().from(schema.sessions).orderBy(desc(schema.sessions.started_at)).limit(limit).all().map((row) => ({
483
+ ...row,
484
+ metadata_json: fromJson(row.metadata_json)
485
+ }));
486
+ return c.json({ items });
487
+ }
488
+ });
489
+ app.get("/api/projects", (c) => {
490
+ const store = getStore();
491
+ {
492
+ const rows = drizzle(store.db, { schema }).selectDistinct({ project: schema.sessions.project }).from(schema.sessions).where(isNotNull(schema.sessions.project)).all();
493
+ const projects = [...new Set(rows.map((r) => String(r.project ?? "").trim()).filter((p) => p && !p.toLowerCase().startsWith("fatal:")).map((p) => projectBasename(p)).filter(Boolean))].sort();
494
+ return c.json({ projects });
495
+ }
496
+ });
497
+ app.get("/api/memories", (c) => c.redirect("/api/observations", 301));
498
+ app.get("/api/observations", (c) => {
499
+ const store = getStore();
500
+ {
501
+ const limit = Math.max(1, queryInt(c.req.query("limit"), 20));
502
+ const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
503
+ const project = c.req.query("project") || void 0;
504
+ const kinds = [
505
+ "bugfix",
506
+ "change",
507
+ "decision",
508
+ "discovery",
509
+ "exploration",
510
+ "feature",
511
+ "refactor"
512
+ ];
513
+ const filters = {};
514
+ if (project) filters.project = project;
515
+ const items = store.recentByKinds(kinds, limit + 1, filters, offset);
516
+ const hasMore = items.length > limit;
517
+ const result = hasMore ? items.slice(0, limit) : items;
518
+ const asRecords = result;
519
+ attachSessionFields(store, asRecords);
520
+ return c.json({
521
+ items: asRecords,
522
+ pagination: {
523
+ limit,
524
+ offset,
525
+ next_offset: hasMore ? offset + result.length : null,
526
+ has_more: hasMore
527
+ }
528
+ });
529
+ }
530
+ });
531
+ app.get("/api/summaries", (c) => {
532
+ const store = getStore();
533
+ {
534
+ const limit = Math.max(1, queryInt(c.req.query("limit"), 50));
535
+ const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
536
+ const project = c.req.query("project") || void 0;
537
+ const filters = { kind: "session_summary" };
538
+ if (project) filters.project = project;
539
+ const items = store.recent(limit + 1, filters, offset);
540
+ const hasMore = items.length > limit;
541
+ const result = hasMore ? items.slice(0, limit) : items;
542
+ const asRecords = result;
543
+ attachSessionFields(store, asRecords);
544
+ return c.json({
545
+ items: asRecords,
546
+ pagination: {
547
+ limit,
548
+ offset,
549
+ next_offset: hasMore ? offset + result.length : null,
550
+ has_more: hasMore
551
+ }
552
+ });
553
+ }
554
+ });
555
+ app.get("/api/session", (c) => {
556
+ const store = getStore();
557
+ {
558
+ const project = c.req.query("project") || null;
559
+ const count = (sql, ...params) => {
560
+ const row = store.db.prepare(sql).get(...params);
561
+ return Number(row?.total ?? 0);
562
+ };
563
+ let prompts;
564
+ let artifacts;
565
+ let memories;
566
+ let observations;
567
+ if (project) {
568
+ prompts = count("SELECT COUNT(*) AS total FROM user_prompts WHERE project = ?", project);
569
+ artifacts = count(`SELECT COUNT(*) AS total FROM artifacts
570
+ JOIN sessions ON sessions.id = artifacts.session_id
571
+ WHERE sessions.project = ?`, project);
572
+ memories = count(`SELECT COUNT(*) AS total FROM memory_items
573
+ JOIN sessions ON sessions.id = memory_items.session_id
574
+ WHERE sessions.project = ?`, project);
575
+ observations = count(`SELECT COUNT(*) AS total FROM memory_items
576
+ JOIN sessions ON sessions.id = memory_items.session_id
577
+ WHERE kind != 'session_summary' AND sessions.project = ?`, project);
578
+ } else {
579
+ prompts = count("SELECT COUNT(*) AS total FROM user_prompts");
580
+ artifacts = count("SELECT COUNT(*) AS total FROM artifacts");
581
+ memories = count("SELECT COUNT(*) AS total FROM memory_items");
582
+ observations = count("SELECT COUNT(*) AS total FROM memory_items WHERE kind != 'session_summary'");
583
+ }
584
+ const total = prompts + artifacts + memories;
585
+ return c.json({
586
+ total,
587
+ memories,
588
+ artifacts,
589
+ prompts,
590
+ observations
591
+ });
592
+ }
593
+ });
594
+ app.get("/api/pack", (c) => {
595
+ const store = getStore();
596
+ {
597
+ const context = c.req.query("context") || "";
598
+ if (!context) return c.json({ error: "context required" }, 400);
599
+ const limit = queryInt(c.req.query("limit"), 10);
600
+ const tokenBudgetStr = c.req.query("token_budget");
601
+ let tokenBudget;
602
+ if (tokenBudgetStr) {
603
+ tokenBudget = parseStrictInteger(tokenBudgetStr) ?? void 0;
604
+ if (tokenBudget === void 0) return c.json({ error: "token_budget must be int" }, 400);
605
+ }
606
+ const project = c.req.query("project") || void 0;
607
+ const filters = {};
608
+ if (project) filters.project = project;
609
+ const pack = store.buildMemoryPack(context, limit, tokenBudget ?? null, filters);
610
+ return c.json(pack);
611
+ }
612
+ });
613
+ app.get("/api/memory", (c) => {
614
+ const store = getStore();
615
+ {
616
+ const limit = queryInt(c.req.query("limit"), 20);
617
+ const kind = c.req.query("kind") || void 0;
618
+ const project = c.req.query("project") || void 0;
619
+ const filters = {};
620
+ if (kind) filters.kind = kind;
621
+ if (project) filters.project = project;
622
+ const asRecords = store.recent(limit, filters);
623
+ attachSessionFields(store, asRecords);
624
+ return c.json({ items: asRecords });
625
+ }
626
+ });
627
+ app.get("/api/artifacts", (c) => {
628
+ const store = getStore();
629
+ {
630
+ const sessionIdStr = c.req.query("session_id");
631
+ if (!sessionIdStr) return c.json({ error: "session_id required" }, 400);
632
+ const sessionId = parseStrictInteger(sessionIdStr);
633
+ if (sessionId == null) return c.json({ error: "session_id must be int" }, 400);
634
+ const rows = drizzle(store.db, { schema }).select().from(schema.artifacts).where(eq(schema.artifacts.session_id, sessionId)).all();
635
+ return c.json({ items: rows });
636
+ }
637
+ });
638
+ app.post("/api/memories/visibility", async (c) => {
639
+ const store = getStore();
640
+ const body = await c.req.json();
641
+ const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
642
+ if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
643
+ const visibility = String(body.visibility ?? "").trim();
644
+ if (visibility !== "private" && visibility !== "shared") return c.json({ error: "visibility must be private or shared" }, 400);
645
+ try {
646
+ const item = store.updateMemoryVisibility(memoryId, visibility);
647
+ return c.json({ item });
648
+ } catch (err) {
649
+ const msg = err instanceof Error ? err.message : String(err);
650
+ if (msg.includes("not found")) return c.json({ error: msg }, 404);
651
+ if (msg.includes("not owned")) return c.json({ error: msg }, 403);
652
+ return c.json({ error: msg }, 400);
653
+ }
654
+ });
655
+ return app;
656
+ }
657
+ //#endregion
658
+ //#region src/routes/observer-status.ts
659
+ function buildFailureImpact(latestFailure, queueTotals, authBackoff) {
660
+ if (!latestFailure) return null;
661
+ if (authBackoff.active) return `Queue retries paused for ~${authBackoff.remainingS}s after an observer auth failure.`;
662
+ if (queueTotals.pending > 0) return `${queueTotals.pending} queued raw events across ${queueTotals.sessions} session(s) are waiting on a successful flush.`;
663
+ return "Failed flush batches are pending retry.";
664
+ }
665
+ function observerStatusRoutes(deps) {
666
+ const app = new Hono();
667
+ app.get("/api/observer-status", (c) => {
668
+ const store = deps?.getStore();
669
+ const sweeper = deps?.getSweeper();
670
+ if (!store || typeof store.rawEventBacklogTotals !== "function") return c.json({
671
+ active: null,
672
+ available_credentials: {},
673
+ latest_failure: null,
674
+ queue: {
675
+ pending: 0,
676
+ sessions: 0,
677
+ auth_backoff_active: false,
678
+ auth_backoff_remaining_s: 0
679
+ }
680
+ });
681
+ const queueTotals = store.rawEventBacklogTotals();
682
+ const authBackoff = sweeper?.authBackoffStatus() ?? {
683
+ active: false,
684
+ remainingS: 0
685
+ };
686
+ const latestFailure = store.latestRawEventFlushFailure();
687
+ const failureWithImpact = latestFailure ? {
688
+ ...latestFailure,
689
+ impact: buildFailureImpact(latestFailure, queueTotals, authBackoff)
690
+ } : null;
691
+ return c.json({
692
+ active: null,
693
+ available_credentials: {},
694
+ latest_failure: failureWithImpact,
695
+ queue: {
696
+ ...queueTotals,
697
+ auth_backoff_active: authBackoff.active,
698
+ auth_backoff_remaining_s: authBackoff.remainingS
699
+ }
700
+ });
701
+ });
702
+ return app;
703
+ }
704
+ //#endregion
705
+ //#region src/routes/raw-events.ts
706
+ /**
707
+ * Raw events routes — GET & POST /api/raw-events, GET /api/raw-events/status,
708
+ * POST /api/claude-hooks.
709
+ */
710
+ var MAX_RAW_EVENTS_BODY_BYTES = Number.parseInt(process.env.CODEMEM_RAW_EVENTS_MAX_BODY_BYTES ?? "", 10) || 1048576;
711
+ /** Keys to check (in priority order) when resolving a session stream id. */
712
+ var SESSION_ID_KEYS = [
713
+ "session_stream_id",
714
+ "session_id",
715
+ "stream_id",
716
+ "opencode_session_id"
717
+ ];
718
+ /**
719
+ * Resolve a session stream id from a payload object.
720
+ * Checks multiple field aliases. Throws on conflicting values.
721
+ */
722
+ function resolveSessionStreamId(payload) {
723
+ const values = /* @__PURE__ */ new Map();
724
+ for (const key of SESSION_ID_KEYS) {
725
+ const value = payload[key];
726
+ if (value == null) continue;
727
+ if (typeof value !== "string") throw new Error(`${key} must be string`);
728
+ const text = value.trim();
729
+ if (text) values.set(key, text);
730
+ }
731
+ if (values.size === 0) return null;
732
+ if (new Set(values.values()).size > 1) throw new Error("conflicting session id fields");
733
+ for (const key of SESSION_ID_KEYS) {
734
+ const v = values.get(key);
735
+ if (v) return v;
736
+ }
737
+ return null;
738
+ }
739
+ /**
740
+ * Parse and validate a JSON object body, enforcing size limits.
741
+ * Returns the parsed payload or a Hono Response on error.
742
+ */
743
+ async function parseJsonObjectBody(c, maxBytes) {
744
+ const contentLength = Number.parseInt(c.req.header("content-length") ?? "0", 10);
745
+ if (Number.isNaN(contentLength) || contentLength < 0) return c.json({ error: "invalid content-length" }, 400);
746
+ if (contentLength > maxBytes) return c.json({
747
+ error: "payload too large",
748
+ max_bytes: maxBytes
749
+ }, 413);
750
+ let raw;
751
+ try {
752
+ raw = await c.req.text();
753
+ } catch {
754
+ return c.json({ error: "invalid json" }, 400);
755
+ }
756
+ if (Buffer.byteLength(raw, "utf-8") > maxBytes) return c.json({
757
+ error: "payload too large",
758
+ max_bytes: maxBytes
759
+ }, 413);
760
+ let parsed;
761
+ try {
762
+ parsed = raw ? JSON.parse(raw) : {};
763
+ } catch {
764
+ return c.json({ error: "invalid json" }, 400);
765
+ }
766
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return c.json({ error: "payload must be an object" }, 400);
767
+ return parsed;
768
+ }
769
+ /** Nudge the sweeper safely — never crashes the caller. */
770
+ function nudgeSweeper(sweeper) {
771
+ try {
772
+ sweeper?.nudge();
773
+ } catch {}
774
+ }
775
+ function rawEventsRoutes(getStore, sweeper) {
776
+ const app = new Hono();
777
+ app.get("/api/raw-events", (c) => {
778
+ const totals = getStore().rawEventBacklogTotals();
779
+ return c.json(totals);
780
+ });
781
+ app.get("/api/raw-events/status", (c) => {
782
+ const store = getStore();
783
+ const limit = queryInt(c.req.query("limit"), 25);
784
+ const items = drizzle(store.db, { schema }).select({
785
+ source: schema.rawEventSessions.source,
786
+ stream_id: schema.rawEventSessions.stream_id,
787
+ opencode_session_id: schema.rawEventSessions.opencode_session_id,
788
+ cwd: schema.rawEventSessions.cwd,
789
+ project: schema.rawEventSessions.project,
790
+ started_at: schema.rawEventSessions.started_at,
791
+ last_seen_ts_wall_ms: schema.rawEventSessions.last_seen_ts_wall_ms,
792
+ last_received_event_seq: schema.rawEventSessions.last_received_event_seq,
793
+ last_flushed_event_seq: schema.rawEventSessions.last_flushed_event_seq,
794
+ updated_at: schema.rawEventSessions.updated_at
795
+ }).from(schema.rawEventSessions).orderBy(desc(schema.rawEventSessions.updated_at)).limit(limit).all().map((row) => {
796
+ const streamId = String(row.stream_id ?? row.opencode_session_id ?? "");
797
+ return {
798
+ ...row,
799
+ session_stream_id: streamId,
800
+ session_id: streamId
801
+ };
802
+ });
803
+ const totals = store.rawEventBacklogTotals();
804
+ return c.json({
805
+ items,
806
+ totals,
807
+ ingest: {
808
+ available: true,
809
+ mode: "stream_queue",
810
+ max_body_bytes: MAX_RAW_EVENTS_BODY_BYTES
811
+ }
812
+ });
813
+ });
814
+ app.post("/api/raw-events", async (c) => {
815
+ const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
816
+ if (result instanceof Response) return result;
817
+ const payload = result;
818
+ const store = getStore();
819
+ try {
820
+ const cwd = payload.cwd;
821
+ if (cwd != null && typeof cwd !== "string") return c.json({ error: "cwd must be string" }, 400);
822
+ const project = payload.project;
823
+ if (project != null && typeof project !== "string") return c.json({ error: "project must be string" }, 400);
824
+ const startedAt = payload.started_at;
825
+ if (startedAt != null && typeof startedAt !== "string") return c.json({ error: "started_at must be string" }, 400);
826
+ let items = payload.events;
827
+ if (items == null) items = [payload];
828
+ if (!Array.isArray(items)) return c.json({ error: "events must be a list" }, 400);
829
+ let defaultSessionId;
830
+ try {
831
+ defaultSessionId = resolveSessionStreamId(payload) ?? "";
832
+ } catch (err) {
833
+ return c.json({ error: err.message }, 400);
834
+ }
835
+ if (defaultSessionId.startsWith("msg_")) return c.json({ error: "invalid session id" }, 400);
836
+ let inserted = 0;
837
+ const lastSeenBySession = /* @__PURE__ */ new Map();
838
+ const metaBySession = /* @__PURE__ */ new Map();
839
+ const sessionIds = /* @__PURE__ */ new Set();
840
+ const batchBySession = /* @__PURE__ */ new Map();
841
+ for (const item of items) {
842
+ if (item == null || typeof item !== "object" || Array.isArray(item)) return c.json({ error: "event must be an object" }, 400);
843
+ const itemObj = item;
844
+ let itemSessionId;
845
+ try {
846
+ itemSessionId = resolveSessionStreamId(itemObj);
847
+ } catch (err) {
848
+ return c.json({ error: err.message }, 400);
849
+ }
850
+ const opencodeSessionId = String(itemSessionId ?? defaultSessionId ?? "");
851
+ if (!opencodeSessionId) return c.json({ error: "session id required" }, 400);
852
+ if (opencodeSessionId.startsWith("msg_")) return c.json({ error: "invalid session id" }, 400);
853
+ let eventId = String(itemObj.event_id ?? "");
854
+ const eventType = String(itemObj.event_type ?? "");
855
+ if (!eventType) return c.json({ error: "event_type required" }, 400);
856
+ const eventSeqValue = itemObj.event_seq;
857
+ if (eventSeqValue != null) {
858
+ const parsed = Number(eventSeqValue);
859
+ if (!Number.isFinite(parsed) || parsed !== Math.floor(parsed)) return c.json({ error: "event_seq must be int" }, 400);
860
+ }
861
+ let tsWallMs = itemObj.ts_wall_ms;
862
+ if (tsWallMs != null) {
863
+ const parsed = Number(tsWallMs);
864
+ if (!Number.isFinite(parsed)) return c.json({ error: "ts_wall_ms must be int" }, 400);
865
+ tsWallMs = Math.floor(parsed);
866
+ const prev = lastSeenBySession.get(opencodeSessionId) ?? tsWallMs;
867
+ lastSeenBySession.set(opencodeSessionId, Math.max(prev, tsWallMs));
868
+ }
869
+ let tsMonoMs = itemObj.ts_mono_ms;
870
+ if (tsMonoMs != null) {
871
+ const parsed = Number(tsMonoMs);
872
+ if (!Number.isFinite(parsed)) return c.json({ error: "ts_mono_ms must be number" }, 400);
873
+ tsMonoMs = parsed;
874
+ }
875
+ let eventPayload = itemObj.payload;
876
+ if (eventPayload == null) eventPayload = {};
877
+ if (typeof eventPayload !== "object" || Array.isArray(eventPayload)) return c.json({ error: "payload must be an object" }, 400);
878
+ const itemCwd = itemObj.cwd;
879
+ if (itemCwd != null && typeof itemCwd !== "string") return c.json({ error: "cwd must be string" }, 400);
880
+ const itemProject = itemObj.project;
881
+ if (itemProject != null && typeof itemProject !== "string") return c.json({ error: "project must be string" }, 400);
882
+ const itemStartedAt = itemObj.started_at;
883
+ if (itemStartedAt != null && typeof itemStartedAt !== "string") return c.json({ error: "started_at must be string" }, 400);
884
+ eventPayload = stripPrivateObj(eventPayload);
885
+ if (!eventId) {
886
+ const sortedStringify = (obj) => JSON.stringify(obj, (_key, value) => {
887
+ if (value != null && typeof value === "object" && !Array.isArray(value)) {
888
+ const sorted = {};
889
+ for (const k of Object.keys(value).sort()) sorted[k] = value[k];
890
+ return sorted;
891
+ }
892
+ return value;
893
+ });
894
+ if (eventSeqValue != null) {
895
+ const rawId = sortedStringify({
896
+ s: eventSeqValue,
897
+ t: eventType,
898
+ p: eventPayload
899
+ });
900
+ eventId = `legacy-seq-${eventSeqValue}-${createHash("sha256").update(rawId, "utf-8").digest("hex").slice(0, 16)}`;
901
+ } else {
902
+ const rawId = sortedStringify({
903
+ m: tsMonoMs ?? null,
904
+ p: eventPayload,
905
+ t: eventType,
906
+ w: tsWallMs ?? null
907
+ });
908
+ eventId = `legacy-${createHash("sha256").update(rawId, "utf-8").digest("hex").slice(0, 16)}`;
909
+ }
910
+ }
911
+ const eventEntry = {
912
+ event_id: eventId,
913
+ event_type: eventType,
914
+ payload: eventPayload,
915
+ ts_wall_ms: tsWallMs ?? null,
916
+ ts_mono_ms: tsMonoMs ?? null
917
+ };
918
+ sessionIds.add(opencodeSessionId);
919
+ const list = batchBySession.get(opencodeSessionId) ?? [];
920
+ list.push({ ...eventEntry });
921
+ batchBySession.set(opencodeSessionId, list);
922
+ if (itemCwd || itemProject || itemStartedAt) {
923
+ const perSession = metaBySession.get(opencodeSessionId) ?? {};
924
+ if (itemCwd) perSession.cwd = itemCwd;
925
+ if (itemProject) perSession.project = itemProject;
926
+ if (itemStartedAt) perSession.started_at = itemStartedAt;
927
+ metaBySession.set(opencodeSessionId, perSession);
928
+ }
929
+ }
930
+ if (sessionIds.size === 1) {
931
+ const singleSessionId = sessionIds.values().next().value;
932
+ const batch = batchBySession.get(singleSessionId) ?? [];
933
+ inserted = store.recordRawEventsBatch(singleSessionId, batch).inserted;
934
+ } else for (const [sid, sidEvents] of batchBySession) {
935
+ const result = store.recordRawEventsBatch(sid, sidEvents);
936
+ inserted += result.inserted;
937
+ }
938
+ for (const metaSessionId of sessionIds) {
939
+ const sessionMeta = metaBySession.get(metaSessionId) ?? {};
940
+ const applyRequestMeta = sessionIds.size === 1 || metaSessionId === defaultSessionId;
941
+ store.updateRawEventSessionMeta({
942
+ opencodeSessionId: metaSessionId,
943
+ cwd: sessionMeta.cwd ?? (applyRequestMeta ? cwd : void 0) ?? null,
944
+ project: sessionMeta.project ?? (applyRequestMeta ? project : void 0) ?? null,
945
+ startedAt: sessionMeta.started_at ?? (applyRequestMeta ? startedAt : void 0) ?? null,
946
+ lastSeenTsWallMs: lastSeenBySession.get(metaSessionId) ?? null
947
+ });
948
+ }
949
+ nudgeSweeper(sweeper);
950
+ return c.json({
951
+ inserted,
952
+ received: items.length
953
+ });
954
+ } catch (err) {
955
+ const response = { error: "internal server error" };
956
+ if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
957
+ return c.json(response, 500);
958
+ }
959
+ });
960
+ app.post("/api/claude-hooks", async (c) => {
961
+ const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
962
+ if (result instanceof Response) return result;
963
+ const envelope = buildRawEventEnvelopeFromHook(result);
964
+ if (envelope === null) return c.json({
965
+ inserted: 0,
966
+ skipped: 1
967
+ });
968
+ const store = getStore();
969
+ try {
970
+ const opencodeSessionId = envelope.opencode_session_id;
971
+ const source = envelope.source;
972
+ const strippedPayload = stripPrivateObj(envelope.payload);
973
+ const inserted = store.recordRawEvent({
974
+ opencodeSessionId,
975
+ source,
976
+ eventId: envelope.event_id,
977
+ eventType: "claude.hook",
978
+ payload: strippedPayload,
979
+ tsWallMs: envelope.ts_wall_ms
980
+ });
981
+ store.updateRawEventSessionMeta({
982
+ opencodeSessionId,
983
+ source,
984
+ cwd: envelope.cwd,
985
+ project: envelope.project,
986
+ startedAt: envelope.started_at,
987
+ lastSeenTsWallMs: envelope.ts_wall_ms
988
+ });
989
+ nudgeSweeper(sweeper);
990
+ return c.json({
991
+ inserted: inserted ? 1 : 0,
992
+ skipped: 0
993
+ });
994
+ } catch (err) {
995
+ const response = { error: "internal server error" };
996
+ if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
997
+ return c.json(response, 500);
998
+ }
999
+ });
1000
+ return app;
1001
+ }
1002
+ //#endregion
1003
+ //#region src/routes/stats.ts
1004
+ /**
1005
+ * Create stats routes. The store factory is called per-request to get a
1006
+ * fresh connection (matching the Python viewer pattern).
1007
+ */
1008
+ function statsRoutes(getStore) {
1009
+ const app = new Hono();
1010
+ app.get("/api/stats", (c) => {
1011
+ const store = getStore();
1012
+ return c.json(store.stats());
1013
+ });
1014
+ app.get("/api/usage", (c) => {
1015
+ const store = getStore();
1016
+ {
1017
+ const projectFilter = c.req.query("project") || null;
1018
+ const eventsGlobal = store.db.prepare(`SELECT event,
1019
+ SUM(tokens_read) AS total_tokens_read,
1020
+ SUM(tokens_written) AS total_tokens_written,
1021
+ SUM(tokens_saved) AS total_tokens_saved,
1022
+ COUNT(*) AS count
1023
+ FROM usage_events GROUP BY event ORDER BY event`).all();
1024
+ const totalsGlobal = store.db.prepare(`SELECT COALESCE(SUM(tokens_read),0) AS tokens_read,
1025
+ COALESCE(SUM(tokens_written),0) AS tokens_written,
1026
+ COALESCE(SUM(tokens_saved),0) AS tokens_saved,
1027
+ COUNT(*) AS count
1028
+ FROM usage_events`).get();
1029
+ let eventsFiltered = null;
1030
+ let totalsFiltered = null;
1031
+ if (projectFilter) {
1032
+ eventsFiltered = store.db.prepare(`SELECT event,
1033
+ SUM(tokens_read) AS total_tokens_read,
1034
+ SUM(tokens_written) AS total_tokens_written,
1035
+ SUM(tokens_saved) AS total_tokens_saved,
1036
+ COUNT(*) AS count
1037
+ FROM usage_events
1038
+ JOIN sessions ON sessions.id = usage_events.session_id
1039
+ WHERE sessions.project = ?
1040
+ GROUP BY event ORDER BY event`).all(projectFilter);
1041
+ totalsFiltered = store.db.prepare(`SELECT COALESCE(SUM(tokens_read),0) AS tokens_read,
1042
+ COALESCE(SUM(tokens_written),0) AS tokens_written,
1043
+ COALESCE(SUM(tokens_saved),0) AS tokens_saved,
1044
+ COUNT(*) AS count
1045
+ FROM usage_events
1046
+ JOIN sessions ON sessions.id = usage_events.session_id
1047
+ WHERE sessions.project = ?`).get(projectFilter);
1048
+ }
1049
+ return c.json({
1050
+ project: projectFilter,
1051
+ events: projectFilter ? eventsFiltered : eventsGlobal,
1052
+ totals: projectFilter ? totalsFiltered : totalsGlobal,
1053
+ events_global: eventsGlobal,
1054
+ totals_global: totalsGlobal,
1055
+ events_filtered: eventsFiltered,
1056
+ totals_filtered: totalsFiltered,
1057
+ recent_packs: []
1058
+ });
1059
+ }
1060
+ });
1061
+ return app;
1062
+ }
1063
+ //#endregion
1064
+ //#region src/routes/sync.ts
1065
+ var SYNC_STALE_AFTER_SECONDS = 600;
1066
+ var PAIRING_FILTER_HINT = "Run this on another device with codemem sync pair --accept '<payload>'. On that accepting device, --include/--exclude only control what it sends to peers. This device does not yet enforce incoming project filters.";
1067
+ /**
1068
+ * Map a raw sync_peers DB row to the API response shape.
1069
+ * When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
1070
+ * are redacted.
1071
+ */
1072
+ function mapPeerRow(row, showDiag) {
1073
+ return {
1074
+ peer_device_id: row.peer_device_id,
1075
+ name: row.name,
1076
+ fingerprint: showDiag ? row.pinned_fingerprint : null,
1077
+ pinned: Boolean(row.pinned_fingerprint),
1078
+ addresses: showDiag ? safeJsonList(row.addresses_json) : [],
1079
+ last_seen_at: row.last_seen_at,
1080
+ last_sync_at: row.last_sync_at,
1081
+ last_error: showDiag ? row.last_error : null,
1082
+ has_error: Boolean(row.last_error),
1083
+ claimed_local_actor: Boolean(row.claimed_local_actor),
1084
+ actor_id: row.actor_id ?? null,
1085
+ actor_display_name: row.actor_display_name ?? null,
1086
+ project_scope: {
1087
+ include: safeJsonList(row.projects_include_json),
1088
+ exclude: safeJsonList(row.projects_exclude_json),
1089
+ effective_include: safeJsonList(row.projects_include_json),
1090
+ effective_exclude: safeJsonList(row.projects_exclude_json),
1091
+ inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
1092
+ }
1093
+ };
1094
+ }
1095
+ function isRecentIso(value, windowS = SYNC_STALE_AFTER_SECONDS) {
1096
+ const raw = String(value ?? "").trim();
1097
+ if (!raw) return false;
1098
+ const normalized = raw.replace("Z", "+00:00");
1099
+ const hasOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw);
1100
+ const ts = new Date(hasOffset ? normalized : `${normalized}+00:00`);
1101
+ if (Number.isNaN(ts.getTime())) return false;
1102
+ const ageS = (Date.now() - ts.getTime()) / 1e3;
1103
+ return ageS >= 0 && ageS <= windowS;
1104
+ }
1105
+ function peerStatus(peer) {
1106
+ const lastSyncAt = peer.last_sync_at;
1107
+ const lastPingAt = peer.last_seen_at;
1108
+ const hasError = Boolean(peer.has_error);
1109
+ const syncFresh = isRecentIso(lastSyncAt);
1110
+ const pingFresh = isRecentIso(lastPingAt);
1111
+ let peerState;
1112
+ if (hasError && !(syncFresh || pingFresh)) peerState = "offline";
1113
+ else if (hasError) peerState = "degraded";
1114
+ else if (syncFresh || pingFresh) peerState = "online";
1115
+ else if (lastSyncAt || lastPingAt) peerState = "stale";
1116
+ else peerState = "unknown";
1117
+ return {
1118
+ sync_status: hasError ? "error" : syncFresh ? "ok" : lastSyncAt ? "stale" : "unknown",
1119
+ ping_status: pingFresh ? "ok" : lastPingAt ? "stale" : "unknown",
1120
+ peer_state: peerState,
1121
+ fresh: syncFresh || pingFresh,
1122
+ last_sync_at: lastSyncAt,
1123
+ last_ping_at: lastPingAt
1124
+ };
1125
+ }
1126
+ function attemptStatus(attempt) {
1127
+ if (attempt.ok) return "ok";
1128
+ if (attempt.error) return "error";
1129
+ return "unknown";
1130
+ }
1131
+ var PEERS_QUERY = `
1132
+ SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
1133
+ p.last_seen_at, p.last_sync_at, p.last_error,
1134
+ p.projects_include_json, p.projects_exclude_json, p.claimed_local_actor,
1135
+ p.actor_id, a.display_name AS actor_display_name
1136
+ FROM sync_peers AS p
1137
+ LEFT JOIN actors AS a ON a.actor_id = p.actor_id
1138
+ ORDER BY name, peer_device_id
1139
+ `;
1140
+ function syncRoutes(getStore) {
1141
+ const app = new Hono();
1142
+ app.get("/api/sync/status", (c) => {
1143
+ const store = getStore();
1144
+ {
1145
+ const showDiag = queryBool(c.req.query("includeDiagnostics"));
1146
+ c.req.query("project");
1147
+ const d = drizzle(store.db, { schema });
1148
+ const deviceRow = d.select({
1149
+ device_id: schema.syncDevice.device_id,
1150
+ fingerprint: schema.syncDevice.fingerprint
1151
+ }).from(schema.syncDevice).limit(1).get();
1152
+ const daemonState = d.select().from(schema.syncDaemonState).where(eq(schema.syncDaemonState.id, 1)).get();
1153
+ const peerCountRow = d.select({ total: count() }).from(schema.syncPeers).get();
1154
+ const lastSyncRow = d.select({ last_sync_at: max(schema.syncPeers.last_sync_at) }).from(schema.syncPeers).get();
1155
+ const lastError = daemonState?.last_error;
1156
+ const lastErrorAt = daemonState?.last_error_at;
1157
+ const lastOkAt = daemonState?.last_ok_at;
1158
+ let daemonStateValue = "ok";
1159
+ if (lastError && (!lastOkAt || String(lastOkAt) < String(lastErrorAt ?? ""))) daemonStateValue = "error";
1160
+ const statusPayload = {
1161
+ enabled: true,
1162
+ interval_s: 60,
1163
+ peer_count: Number(peerCountRow?.total ?? 0),
1164
+ last_sync_at: lastSyncRow?.last_sync_at ?? null,
1165
+ daemon_state: daemonStateValue,
1166
+ daemon_running: false,
1167
+ daemon_detail: null,
1168
+ project_filter_active: false,
1169
+ project_filter: {
1170
+ include: [],
1171
+ exclude: []
1172
+ },
1173
+ redacted: !showDiag
1174
+ };
1175
+ if (showDiag) {
1176
+ statusPayload.device_id = deviceRow?.device_id ?? null;
1177
+ statusPayload.fingerprint = deviceRow?.fingerprint ?? null;
1178
+ statusPayload.bind = null;
1179
+ statusPayload.daemon_last_error = lastError;
1180
+ statusPayload.daemon_last_error_at = lastErrorAt;
1181
+ statusPayload.daemon_last_ok_at = lastOkAt;
1182
+ }
1183
+ const peersItems = store.db.prepare(PEERS_QUERY).all().map((row) => {
1184
+ const peer = mapPeerRow(row, showDiag);
1185
+ peer.status = peerStatus(peer);
1186
+ return peer;
1187
+ });
1188
+ const peersMap = {};
1189
+ for (const peer of peersItems) peersMap[String(peer.peer_device_id)] = peer.status;
1190
+ const attemptsItems = d.select({
1191
+ peer_device_id: schema.syncAttempts.peer_device_id,
1192
+ ok: schema.syncAttempts.ok,
1193
+ error: schema.syncAttempts.error,
1194
+ started_at: schema.syncAttempts.started_at,
1195
+ finished_at: schema.syncAttempts.finished_at,
1196
+ ops_in: schema.syncAttempts.ops_in,
1197
+ ops_out: schema.syncAttempts.ops_out
1198
+ }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(25).all().map((row) => ({
1199
+ ...row,
1200
+ status: attemptStatus(row),
1201
+ address: null
1202
+ }));
1203
+ const statusBlock = {
1204
+ ...statusPayload,
1205
+ peers: peersMap,
1206
+ pending: 0,
1207
+ sync: {},
1208
+ ping: {}
1209
+ };
1210
+ return c.json({
1211
+ ...statusPayload,
1212
+ status: statusBlock,
1213
+ peers: peersItems,
1214
+ attempts: attemptsItems.slice(0, 5),
1215
+ legacy_devices: [],
1216
+ sharing_review: { unreviewed: 0 },
1217
+ coordinator: {
1218
+ enabled: false,
1219
+ configured: false
1220
+ },
1221
+ join_requests: []
1222
+ });
1223
+ }
1224
+ });
1225
+ app.get("/api/sync/peers", (c) => {
1226
+ const store = getStore();
1227
+ {
1228
+ const showDiag = queryBool(c.req.query("includeDiagnostics"));
1229
+ const peers = store.db.prepare(PEERS_QUERY).all().map((row) => mapPeerRow(row, showDiag));
1230
+ return c.json({
1231
+ items: peers,
1232
+ redacted: !showDiag
1233
+ });
1234
+ }
1235
+ });
1236
+ app.get("/api/sync/actors", (c) => {
1237
+ const store = getStore();
1238
+ {
1239
+ const d = drizzle(store.db, { schema });
1240
+ const includeMerged = queryBool(c.req.query("includeMerged"));
1241
+ const query = d.select().from(schema.actors);
1242
+ const rows = includeMerged ? query.orderBy(schema.actors.display_name).all() : query.where(ne(schema.actors.status, "merged")).orderBy(schema.actors.display_name).all();
1243
+ return c.json({ items: rows });
1244
+ }
1245
+ });
1246
+ app.get("/api/sync/attempts", (c) => {
1247
+ const store = getStore();
1248
+ {
1249
+ const d = drizzle(store.db, { schema });
1250
+ let limit = queryInt(c.req.query("limit"), 25);
1251
+ if (limit <= 0) return c.json({ error: "invalid_limit" }, 400);
1252
+ limit = Math.min(limit, 500);
1253
+ const rows = d.select({
1254
+ peer_device_id: schema.syncAttempts.peer_device_id,
1255
+ ok: schema.syncAttempts.ok,
1256
+ error: schema.syncAttempts.error,
1257
+ started_at: schema.syncAttempts.started_at,
1258
+ finished_at: schema.syncAttempts.finished_at,
1259
+ ops_in: schema.syncAttempts.ops_in,
1260
+ ops_out: schema.syncAttempts.ops_out
1261
+ }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(limit).all();
1262
+ return c.json({ items: rows });
1263
+ }
1264
+ });
1265
+ app.get("/api/sync/pairing", (c) => {
1266
+ const store = getStore();
1267
+ {
1268
+ if (!queryBool(c.req.query("includeDiagnostics"))) return c.json({
1269
+ redacted: true,
1270
+ pairing_filter_hint: PAIRING_FILTER_HINT
1271
+ });
1272
+ const d = drizzle(store.db, { schema });
1273
+ const deviceRow = d.select({
1274
+ device_id: schema.syncDevice.device_id,
1275
+ public_key: schema.syncDevice.public_key,
1276
+ fingerprint: schema.syncDevice.fingerprint
1277
+ }).from(schema.syncDevice).limit(1).get();
1278
+ let deviceId;
1279
+ let publicKey;
1280
+ let fingerprint;
1281
+ if (deviceRow) {
1282
+ deviceId = String(deviceRow.device_id);
1283
+ publicKey = String(deviceRow.public_key);
1284
+ fingerprint = String(deviceRow.fingerprint);
1285
+ } else try {
1286
+ const [id, fp] = ensureDeviceIdentity(store.db);
1287
+ deviceId = id;
1288
+ fingerprint = fp;
1289
+ publicKey = d.select({ public_key: schema.syncDevice.public_key }).from(schema.syncDevice).where(eq(schema.syncDevice.device_id, id)).get()?.public_key ?? "";
1290
+ } catch {
1291
+ return c.json({ error: "device identity unavailable" }, 500);
1292
+ }
1293
+ if (!deviceId || !fingerprint) return c.json({ error: "public key missing" }, 500);
1294
+ return c.json({
1295
+ device_id: deviceId,
1296
+ fingerprint,
1297
+ public_key: publicKey ?? null,
1298
+ pairing_filter_hint: PAIRING_FILTER_HINT,
1299
+ addresses: []
1300
+ });
1301
+ }
1302
+ });
1303
+ app.post("/api/sync/peers/rename", async (c) => {
1304
+ const store = getStore();
1305
+ {
1306
+ const d = drizzle(store.db, { schema });
1307
+ const body = await c.req.json();
1308
+ const peerDeviceId = String(body.peer_device_id ?? "").trim();
1309
+ const name = String(body.name ?? "").trim();
1310
+ if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
1311
+ if (!name) return c.json({ error: "name required" }, 400);
1312
+ if (!d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get()) return c.json({ error: "peer not found" }, 404);
1313
+ d.update(schema.syncPeers).set({ name }).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
1314
+ return c.json({ ok: true });
1315
+ }
1316
+ });
1317
+ app.delete("/api/sync/peers/:peer_device_id", (c) => {
1318
+ const store = getStore();
1319
+ {
1320
+ const d = drizzle(store.db, { schema });
1321
+ const peerDeviceId = c.req.param("peer_device_id")?.trim();
1322
+ if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
1323
+ if (!d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get()) return c.json({ error: "peer not found" }, 404);
1324
+ d.delete(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
1325
+ return c.json({ ok: true });
1326
+ }
1327
+ });
1328
+ return app;
1329
+ }
1330
+ //#endregion
1331
+ //#region src/index.ts
1332
+ /**
1333
+ * @codemem/server — HTTP server (viewer, sync, API).
1334
+ *
1335
+ * Single HTTP server handling viewer routes and sync daemon.
1336
+ * Shares one better-sqlite3 connection between viewer and sync.
1337
+ * Embedding inference runs in a worker_thread (lazy-started).
1338
+ *
1339
+ * Entry: `codemem serve`
1340
+ */
1341
+ /** Shared store instance — SQLite WAL mode handles concurrent reads safely. */
1342
+ var sharedStore = null;
1343
+ /** Get (or create) the shared store instance. Exported so the sweeper can share it. */
1344
+ function getStore() {
1345
+ if (!sharedStore) sharedStore = new MemoryStore(resolveDbPath());
1346
+ return sharedStore;
1347
+ }
1348
+ /** Close the shared store (called on shutdown). */
1349
+ function closeStore() {
1350
+ sharedStore?.close();
1351
+ sharedStore = null;
1352
+ }
1353
+ function createApp(opts) {
1354
+ const storeFactory = opts?.storeFactory ?? getStore;
1355
+ const sweeper = opts?.sweeper ?? null;
1356
+ const app = new Hono();
1357
+ app.use("*", preflightHandler());
1358
+ app.use("*", originGuard());
1359
+ app.route("/", statsRoutes(storeFactory));
1360
+ app.route("/", memoryRoutes(storeFactory));
1361
+ app.route("/", observerStatusRoutes({
1362
+ getStore: storeFactory,
1363
+ getSweeper: () => sweeper
1364
+ }));
1365
+ app.route("/", configRoutes({ getSweeper: () => sweeper }));
1366
+ app.route("/", rawEventsRoutes(storeFactory, sweeper));
1367
+ app.route("/", syncRoutes(storeFactory));
1368
+ const staticRoot = process.env.CODEMEM_VIEWER_STATIC_DIR ?? join(import.meta.dirname ?? ".", "../static");
1369
+ app.use("/assets/*", serveStatic({
1370
+ root: staticRoot,
1371
+ rewriteRequestPath: (path) => path.replace(/^\/assets/, "")
1372
+ }));
1373
+ const indexHtml = readFileSync(join(staticRoot, "index.html"), "utf-8");
1374
+ app.get("*", (c) => {
1375
+ if (c.req.path.startsWith("/api/")) return c.json({ error: "not found" }, 404);
1376
+ return c.html(indexHtml);
1377
+ });
1378
+ return app;
1379
+ }
1380
+ //#endregion
1381
+ export { VERSION, closeStore, createApp, getStore };
1382
+
1383
+ //# sourceMappingURL=index.js.map