@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/LICENSE +21 -0
- package/dist/helpers.d.ts +19 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1383 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +33 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/routes/config.d.ts +13 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/memory.d.ts +9 -0
- package/dist/routes/memory.d.ts.map +1 -0
- package/dist/routes/observer-status.d.ts +16 -0
- package/dist/routes/observer-status.d.ts.map +1 -0
- package/dist/routes/raw-events.d.ts +10 -0
- package/dist/routes/raw-events.d.ts.map +1 -0
- package/dist/routes/stats.d.ts +13 -0
- package/dist/routes/stats.d.ts.map +1 -0
- package/dist/routes/sync.d.ts +9 -0
- package/dist/routes/sync.d.ts.map +1 -0
- package/dist/viewer-html.d.ts +8 -0
- package/dist/viewer-html.d.ts.map +1 -0
- package/package.json +46 -1
- package/static/app.js +3726 -0
- package/static/favicon.svg +14 -0
- package/static/index.html +1860 -0
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
|