claude-memory-layer 1.0.53 → 1.0.55

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.
@@ -12,8 +12,8 @@ import { getCookie, setCookie, deleteCookie } from "hono/cookie";
12
12
  import { logger } from "hono/logger";
13
13
  import { serve } from "@hono/node-server";
14
14
  import { serveStatic } from "@hono/node-server/serve-static";
15
- import * as path13 from "path";
16
- import * as fs11 from "fs";
15
+ import * as path15 from "path";
16
+ import * as fs13 from "fs";
17
17
  import { createHmac, timingSafeEqual } from "crypto";
18
18
  import { fileURLToPath as fileUrlToPath } from "url";
19
19
 
@@ -175,7 +175,13 @@ function toDate(value) {
175
175
  return new Date(String(value));
176
176
  }
177
177
  function createDatabase(dbPath, options) {
178
- return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
178
+ const db = new BetterSqlite3(dbPath, { readonly: options?.readOnly });
179
+ db.pragma("busy_timeout = 5000");
180
+ if (!options?.readOnly) {
181
+ db.pragma("journal_mode = WAL");
182
+ db.pragma("synchronous = NORMAL");
183
+ }
184
+ return db;
179
185
  }
180
186
  function dbRun(db, sql, params = []) {
181
187
  db.prepare(sql).run(...params);
@@ -408,19 +414,29 @@ var ConsolidatedStore = class {
408
414
  */
409
415
  async create(input) {
410
416
  const memoryId = randomUUID2();
411
- await dbRun(
412
- this.db,
417
+ const sourceEvents = Array.isArray(input.sourceEvents) ? input.sourceEvents : [];
418
+ const insertMemory = this.db.prepare(
413
419
  `INSERT INTO consolidated_memories
414
420
  (memory_id, summary, topics, source_events, confidence, created_at)
415
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
416
- [
421
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
422
+ );
423
+ const insertEvent = this.db.prepare(
424
+ `INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id) VALUES (?, ?)`
425
+ );
426
+ this.db.transaction(() => {
427
+ insertMemory.run(
417
428
  memoryId,
418
429
  input.summary,
419
430
  JSON.stringify(input.topics),
420
- JSON.stringify(input.sourceEvents),
431
+ JSON.stringify(sourceEvents),
421
432
  input.confidence
422
- ]
423
- );
433
+ );
434
+ for (const eventId of sourceEvents) {
435
+ if (typeof eventId === "string" && eventId.length > 0) {
436
+ insertEvent.run(memoryId, eventId);
437
+ }
438
+ }
439
+ })();
424
440
  return memoryId;
425
441
  }
426
442
  /**
@@ -526,11 +542,12 @@ var ConsolidatedStore = class {
526
542
  * Delete a consolidated memory
527
543
  */
528
544
  async delete(memoryId) {
529
- await dbRun(
530
- this.db,
531
- `DELETE FROM consolidated_memories WHERE memory_id = ?`,
532
- [memoryId]
533
- );
545
+ const deleteEvents = this.db.prepare(`DELETE FROM consolidated_memory_events WHERE memory_id = ?`);
546
+ const deleteMemory = this.db.prepare(`DELETE FROM consolidated_memories WHERE memory_id = ?`);
547
+ this.db.transaction(() => {
548
+ deleteEvents.run(memoryId);
549
+ deleteMemory.run(memoryId);
550
+ })();
534
551
  }
535
552
  /**
536
553
  * Create a long-term rule promoted from stable summaries
@@ -643,16 +660,16 @@ var ConsolidatedStore = class {
643
660
  * Check if source events are already consolidated
644
661
  */
645
662
  async isAlreadyConsolidated(eventIds) {
646
- for (const eventId of eventIds) {
647
- const result = await dbAll(
648
- this.db,
649
- `SELECT COUNT(*) as count FROM consolidated_memories
650
- WHERE source_events LIKE ?`,
651
- [`%"${eventId}"%`]
652
- );
653
- if ((result[0]?.count || 0) > 0) return true;
654
- }
655
- return false;
663
+ if (eventIds.length === 0) return false;
664
+ const placeholders = eventIds.map(() => "?").join(",");
665
+ const rows = await dbAll(
666
+ this.db,
667
+ `SELECT memory_id FROM consolidated_memory_events
668
+ WHERE event_id IN (${placeholders})
669
+ LIMIT 1`,
670
+ eventIds
671
+ );
672
+ return rows.length > 0;
656
673
  }
657
674
  /**
658
675
  * Get the last consolidation time
@@ -2188,6 +2205,23 @@ function makeDedupeKey(content, sessionId) {
2188
2205
  return `${sessionId}:${contentHash}`;
2189
2206
  }
2190
2207
 
2208
+ // src/core/citation-generator.ts
2209
+ import { createHash as createHash2 } from "crypto";
2210
+ var ID_LENGTH = 6;
2211
+ var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2212
+ function generateCitationId(eventId) {
2213
+ const hash = createHash2("sha256").update(eventId).digest();
2214
+ let id = "";
2215
+ for (let i = 0; i < ID_LENGTH; i++) {
2216
+ id += CHARSET[hash[i] % CHARSET.length];
2217
+ }
2218
+ return id;
2219
+ }
2220
+ function parseCitationId(formatted) {
2221
+ const match = formatted.match(/\[?mem:([A-Za-z0-9]{6})\]?/);
2222
+ return match ? match[1] : null;
2223
+ }
2224
+
2191
2225
  // src/core/sqlite-event-store.ts
2192
2226
  import * as nodePath2 from "path";
2193
2227
 
@@ -2221,12 +2255,12 @@ function resolveProjectStoragePath(projectOrHash) {
2221
2255
  import Database from "better-sqlite3";
2222
2256
  import * as fs4 from "fs";
2223
2257
  import * as nodePath from "path";
2224
- function createSQLiteDatabase(path14, options) {
2225
- const dir = nodePath.dirname(path14);
2258
+ function createSQLiteDatabase(path16, options) {
2259
+ const dir = nodePath.dirname(path16);
2226
2260
  if (!fs4.existsSync(dir)) {
2227
2261
  fs4.mkdirSync(dir, { recursive: true });
2228
2262
  }
2229
- const db = new Database(path14, {
2263
+ const db = new Database(path16, {
2230
2264
  readonly: options?.readonly ?? false
2231
2265
  });
2232
2266
  if (!options?.readonly && (options?.walMode ?? true)) {
@@ -2236,17 +2270,28 @@ function createSQLiteDatabase(path14, options) {
2236
2270
  }
2237
2271
  return db;
2238
2272
  }
2273
+ var statementCache = /* @__PURE__ */ new WeakMap();
2274
+ function prepareCached(db, sql) {
2275
+ let cache = statementCache.get(db);
2276
+ if (!cache) {
2277
+ cache = /* @__PURE__ */ new Map();
2278
+ statementCache.set(db, cache);
2279
+ }
2280
+ let stmt = cache.get(sql);
2281
+ if (!stmt) {
2282
+ stmt = db.prepare(sql);
2283
+ cache.set(sql, stmt);
2284
+ }
2285
+ return stmt;
2286
+ }
2239
2287
  function sqliteRun(db, sql, params = []) {
2240
- const stmt = db.prepare(sql);
2241
- return stmt.run(...params);
2288
+ return prepareCached(db, sql).run(...params);
2242
2289
  }
2243
2290
  function sqliteAll(db, sql, params = []) {
2244
- const stmt = db.prepare(sql);
2245
- return stmt.all(...params);
2291
+ return prepareCached(db, sql).all(...params);
2246
2292
  }
2247
2293
  function sqliteGet(db, sql, params = []) {
2248
- const stmt = db.prepare(sql);
2249
- return stmt.get(...params);
2294
+ return prepareCached(db, sql).get(...params);
2250
2295
  }
2251
2296
  function sqliteExec(db, sql) {
2252
2297
  db.exec(sql);
@@ -2668,6 +2713,7 @@ function normalizeQueryRewriteKind(value) {
2668
2713
  var REWRITTEN_QUERY_REWRITE_KIND_SQL = `LOWER(TRIM(COALESCE(query_rewrite_kind, 'none'))) IN ('follow-up-context', 'intent-rewrite')`;
2669
2714
  var DEFAULT_OUTBOX_STUCK_THRESHOLD_MS = 5 * 60 * 1e3;
2670
2715
  var DEFAULT_OUTBOX_MAX_RETRIES = 3;
2716
+ var SQLITE_SCHEMA_VERSION = 1;
2671
2717
  function emptyOutboxRecoveryResult() {
2672
2718
  return {
2673
2719
  embedding: { recoveredProcessing: 0, retriedFailed: 0 },
@@ -2677,17 +2723,17 @@ function emptyOutboxRecoveryResult() {
2677
2723
  function isRecord(value) {
2678
2724
  return typeof value === "object" && value !== null && !Array.isArray(value);
2679
2725
  }
2680
- function getNestedRecord(root, path14) {
2726
+ function getNestedRecord(root, path16) {
2681
2727
  let cursor = root;
2682
- for (const key of path14) {
2728
+ for (const key of path16) {
2683
2729
  if (!isRecord(cursor)) return void 0;
2684
2730
  cursor = cursor[key];
2685
2731
  }
2686
2732
  return isRecord(cursor) ? cursor : void 0;
2687
2733
  }
2688
- function getNestedString(root, path14) {
2734
+ function getNestedString(root, path16) {
2689
2735
  let cursor = root;
2690
- for (const key of path14) {
2736
+ for (const key of path16) {
2691
2737
  if (!isRecord(cursor)) return void 0;
2692
2738
  cursor = cursor[key];
2693
2739
  }
@@ -3012,6 +3058,15 @@ var SQLiteEventStore = class {
3012
3058
  access_count INTEGER DEFAULT 0
3013
3059
  );
3014
3060
 
3061
+ -- Junction: consolidated memory -> source event id (indexed lookup that
3062
+ -- replaces per-event source_events LIKE scans during consolidation).
3063
+ CREATE TABLE IF NOT EXISTS consolidated_memory_events (
3064
+ memory_id TEXT NOT NULL,
3065
+ event_id TEXT NOT NULL,
3066
+ PRIMARY KEY (memory_id, event_id)
3067
+ );
3068
+ CREATE INDEX IF NOT EXISTS idx_cme_event ON consolidated_memory_events(event_id);
3069
+
3015
3070
  -- Continuity Log table (tracks context transitions)
3016
3071
  CREATE TABLE IF NOT EXISTS continuity_log (
3017
3072
  log_id TEXT PRIMARY KEY,
@@ -3371,11 +3426,17 @@ var SQLiteEventStore = class {
3371
3426
  DELETE FROM events_fts WHERE rowid = OLD.rowid;
3372
3427
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
3373
3428
  END;
3429
+
3430
+ -- Reverse index for citation ids (a non-reversible hash of event id), so
3431
+ -- citation lookups are O(1) instead of scanning recent events and hashing
3432
+ -- each one. Populated lazily/self-healing by getEventByCitationId.
3433
+ CREATE TABLE IF NOT EXISTS event_citations (
3434
+ event_id TEXT PRIMARY KEY,
3435
+ citation_id TEXT NOT NULL
3436
+ );
3437
+ CREATE INDEX IF NOT EXISTS idx_event_citations_citation ON event_citations(citation_id);
3374
3438
  `);
3375
- try {
3376
- sqliteExec(this.db, `ALTER TABLE memory_action_edges ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';`);
3377
- } catch {
3378
- }
3439
+ this.addColumnIfMissing("memory_action_edges", "source", `TEXT NOT NULL DEFAULT 'manual'`);
3379
3440
  try {
3380
3441
  const edgeIndexes = sqliteAll(this.db, `PRAGMA index_list(memory_action_edges)`, []);
3381
3442
  const hasSourceAwareUnique = edgeIndexes.some((index) => {
@@ -3412,55 +3473,17 @@ var SQLiteEventStore = class {
3412
3473
  }
3413
3474
  } catch {
3414
3475
  }
3415
- try {
3416
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
3417
- } catch {
3418
- }
3419
- try {
3420
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
3421
- } catch {
3422
- }
3423
- try {
3424
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN raw_query_text TEXT;`);
3425
- } catch {
3426
- }
3427
- try {
3428
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN query_rewrite_kind TEXT;`);
3429
- } catch {
3430
- }
3476
+ this.addColumnIfMissing("retrieval_traces", "selected_details_json", "TEXT");
3477
+ this.addColumnIfMissing("retrieval_traces", "candidate_details_json", "TEXT");
3478
+ this.addColumnIfMissing("retrieval_traces", "raw_query_text", "TEXT");
3479
+ this.addColumnIfMissing("retrieval_traces", "query_rewrite_kind", "TEXT");
3431
3480
  try {
3432
3481
  sqliteExec(this.db, `CREATE INDEX IF NOT EXISTS idx_retrieval_traces_query_rewrite_kind ON retrieval_traces(query_rewrite_kind);`);
3433
3482
  } catch {
3434
3483
  }
3435
- const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
3436
- const columnNames = tableInfo.map((col) => col.name);
3437
- if (!columnNames.includes("access_count")) {
3438
- try {
3439
- sqliteExec(this.db, `
3440
- ALTER TABLE events ADD COLUMN access_count INTEGER DEFAULT 0;
3441
- `);
3442
- } catch (err) {
3443
- console.error("Error adding access_count column:", err);
3444
- }
3445
- }
3446
- if (!columnNames.includes("last_accessed_at")) {
3447
- try {
3448
- sqliteExec(this.db, `
3449
- ALTER TABLE events ADD COLUMN last_accessed_at TEXT;
3450
- `);
3451
- } catch (err) {
3452
- console.error("Error adding last_accessed_at column:", err);
3453
- }
3454
- }
3455
- if (!columnNames.includes("turn_id")) {
3456
- try {
3457
- sqliteExec(this.db, `
3458
- ALTER TABLE events ADD COLUMN turn_id TEXT;
3459
- `);
3460
- } catch (err) {
3461
- console.error("Error adding turn_id column:", err);
3462
- }
3463
- }
3484
+ this.addColumnIfMissing("events", "access_count", "INTEGER DEFAULT 0");
3485
+ this.addColumnIfMissing("events", "last_accessed_at", "TEXT");
3486
+ this.addColumnIfMissing("events", "turn_id", "TEXT");
3464
3487
  try {
3465
3488
  sqliteExec(this.db, `
3466
3489
  CREATE INDEX IF NOT EXISTS idx_events_access_count ON events(access_count DESC);
@@ -3477,8 +3500,18 @@ var SQLiteEventStore = class {
3477
3500
  sqliteExec(this.db, `
3478
3501
  CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
3479
3502
  `);
3480
- } catch (err) {
3503
+ } catch {
3504
+ }
3505
+ const cmeCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM consolidated_memory_events`, []);
3506
+ if ((cmeCount?.c ?? 0) === 0) {
3507
+ sqliteExec(this.db, `
3508
+ INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id)
3509
+ SELECT cm.memory_id, je.value
3510
+ FROM consolidated_memories cm, json_each(cm.source_events) je
3511
+ WHERE json_valid(cm.source_events);
3512
+ `);
3481
3513
  }
3514
+ sqliteExec(this.db, `PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
3482
3515
  this.initialized = true;
3483
3516
  }
3484
3517
  /**
@@ -3610,6 +3643,67 @@ var SQLiteEventStore = class {
3610
3643
  );
3611
3644
  return rows.map(this.rowToEvent);
3612
3645
  }
3646
+ /**
3647
+ * Aggregate event counts by type via SQL GROUP BY, instead of loading rows
3648
+ * into memory and counting in JS. Counts all events (the old recent-window
3649
+ * scan also under-counted stores larger than its cap).
3650
+ */
3651
+ async getEventTypeCounts(options) {
3652
+ await this.initialize();
3653
+ const rows = sqliteAll(
3654
+ this.db,
3655
+ `SELECT event_type, COUNT(*) as count FROM events WHERE ${maybeQuarantinePredicate(options)} GROUP BY event_type`,
3656
+ []
3657
+ );
3658
+ return rows.map((row) => ({ eventType: row.event_type, count: row.count }));
3659
+ }
3660
+ /**
3661
+ * Fetch every event at/after an ISO timestamp (ascending), with no row cap.
3662
+ * For window-scoped analytics (KPI/usefulness) this fetches exactly the events
3663
+ * the window needs instead of an arbitrary "most recent N" slice that both
3664
+ * over-fetches sparse windows and truncates very active ones.
3665
+ */
3666
+ async getEventsAfter(sinceIso, options) {
3667
+ await this.initialize();
3668
+ const rows = sqliteAll(
3669
+ this.db,
3670
+ `SELECT * FROM events WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)} ORDER BY timestamp ASC`,
3671
+ [sinceIso]
3672
+ );
3673
+ return rows.map((row) => this.rowToEvent(row));
3674
+ }
3675
+ /** Count distinct sessions via SQL instead of materializing a Set in JS. */
3676
+ async getDistinctSessionCount(options) {
3677
+ await this.initialize();
3678
+ const row = sqliteGet(
3679
+ this.db,
3680
+ `SELECT COUNT(DISTINCT session_id) as count FROM events WHERE ${maybeQuarantinePredicate(options)}`,
3681
+ []
3682
+ );
3683
+ return row?.count ?? 0;
3684
+ }
3685
+ /**
3686
+ * Per-day event counts (with type breakdown) since an ISO timestamp, computed
3687
+ * by SQL GROUP BY on the date prefix. Timestamps are stored as ISO-8601
3688
+ * strings, so substr(...,1,10) is the UTC day and `timestamp >= ?` compares
3689
+ * lexicographically.
3690
+ */
3691
+ async getDailyEventCounts(sinceIso, options) {
3692
+ await this.initialize();
3693
+ return sqliteAll(
3694
+ this.db,
3695
+ `SELECT substr(timestamp, 1, 10) as day,
3696
+ COUNT(*) as total,
3697
+ SUM(CASE WHEN event_type = 'user_prompt' THEN 1 ELSE 0 END) as prompts,
3698
+ SUM(CASE WHEN event_type = 'agent_response' THEN 1 ELSE 0 END) as responses,
3699
+ SUM(CASE WHEN event_type = 'tool_observation' THEN 1 ELSE 0 END) as tools
3700
+ FROM events
3701
+ WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)}
3702
+ GROUP BY day
3703
+ ORDER BY day ASC`,
3704
+ [sinceIso]
3705
+ );
3706
+ }
3613
3707
  /**
3614
3708
  * Get event by ID
3615
3709
  */
@@ -3623,6 +3717,71 @@ var SQLiteEventStore = class {
3623
3717
  if (!row) return null;
3624
3718
  return this.rowToEvent(row);
3625
3719
  }
3720
+ /**
3721
+ * Batch-fetch events by id in a single query, applying the same quarantine
3722
+ * predicate as getEvent. Lets hot paths replace N sequential round-trips with
3723
+ * one. Missing ids are simply absent from the result (order not guaranteed).
3724
+ */
3725
+ async getEvents(ids, options) {
3726
+ await this.initialize();
3727
+ if (ids.length === 0) return [];
3728
+ const placeholders = ids.map(() => "?").join(",");
3729
+ const rows = sqliteAll(
3730
+ this.db,
3731
+ `SELECT * FROM events WHERE id IN (${placeholders}) AND ${maybeQuarantinePredicate(options)}`,
3732
+ ids
3733
+ );
3734
+ return rows.map((row) => this.rowToEvent(row));
3735
+ }
3736
+ /**
3737
+ * Resolve an event from its citation id via the reverse index, instead of
3738
+ * scanning recent events and hashing each one. The index is self-healing: a
3739
+ * miss triggers a one-time backfill of any events not yet indexed (covering
3740
+ * freshly appended events) before retrying.
3741
+ */
3742
+ async getEventByCitationId(citationId, options) {
3743
+ await this.initialize();
3744
+ if (!citationId) return null;
3745
+ let row = sqliteGet(
3746
+ this.db,
3747
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3748
+ [citationId]
3749
+ );
3750
+ if (!row) {
3751
+ this.indexMissingCitations();
3752
+ row = sqliteGet(
3753
+ this.db,
3754
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3755
+ [citationId]
3756
+ );
3757
+ }
3758
+ if (!row) return null;
3759
+ return this.getEvent(row.event_id, options);
3760
+ }
3761
+ /**
3762
+ * Backfill citation ids for any events not yet present in event_citations.
3763
+ * Gated on a cheap count comparison so the common (fully-indexed) path does no
3764
+ * work, and the scan only runs when there are genuinely new events to index.
3765
+ */
3766
+ indexMissingCitations() {
3767
+ const eventCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM events`, [])?.c ?? 0;
3768
+ const indexedCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM event_citations`, [])?.c ?? 0;
3769
+ if (indexedCount >= eventCount) return;
3770
+ const unindexed = sqliteAll(
3771
+ this.db,
3772
+ `SELECT e.id FROM events e
3773
+ LEFT JOIN event_citations ec ON ec.event_id = e.id
3774
+ WHERE ec.event_id IS NULL`,
3775
+ []
3776
+ );
3777
+ if (unindexed.length === 0) return;
3778
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO event_citations (event_id, citation_id) VALUES (?, ?)`);
3779
+ this.db.transaction(() => {
3780
+ for (const { id } of unindexed) {
3781
+ insert.run(id, generateCitationId(id));
3782
+ }
3783
+ })();
3784
+ }
3626
3785
  /**
3627
3786
  * Get events since a timestamp (for sync)
3628
3787
  */
@@ -3824,25 +3983,22 @@ var SQLiteEventStore = class {
3824
3983
  */
3825
3984
  async getPendingOutboxItems(limit = 32) {
3826
3985
  await this.initialize();
3827
- const pending = sqliteAll(
3828
- this.db,
3829
- `SELECT * FROM embedding_outbox
3830
- WHERE status = 'pending'
3831
- ORDER BY created_at
3832
- LIMIT ?`,
3833
- [limit]
3834
- );
3835
- if (pending.length === 0) return [];
3836
- const ids = pending.map((r) => r.id);
3837
- const placeholders = ids.map(() => "?").join(",");
3838
- sqliteRun(
3986
+ const claimed = sqliteAll(
3839
3987
  this.db,
3840
3988
  `UPDATE embedding_outbox
3841
3989
  SET status = 'processing', processed_at = datetime('now'), error_message = NULL
3842
- WHERE id IN (${placeholders})`,
3843
- ids
3990
+ WHERE id IN (
3991
+ SELECT id FROM embedding_outbox
3992
+ WHERE status = 'pending'
3993
+ ORDER BY created_at
3994
+ LIMIT ?
3995
+ )
3996
+ RETURNING *`,
3997
+ [limit]
3844
3998
  );
3845
- return pending.map((row) => ({
3999
+ if (claimed.length === 0) return [];
4000
+ claimed.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
4001
+ return claimed.map((row) => ({
3846
4002
  id: row.id,
3847
4003
  eventId: row.event_id,
3848
4004
  content: row.content,
@@ -4446,7 +4602,7 @@ var SQLiteEventStore = class {
4446
4602
  const promptEvents = sessionEvents.filter((e) => e.event_type === "user_prompt");
4447
4603
  const toolEvents = sessionEvents.filter((e) => e.event_type === "tool_observation");
4448
4604
  let toolSuccessCount = 0;
4449
- let toolTotalCount = toolEvents.length;
4605
+ const toolTotalCount = toolEvents.length;
4450
4606
  for (const t of toolEvents) {
4451
4607
  try {
4452
4608
  const content = JSON.parse(t.content);
@@ -4609,10 +4765,14 @@ var SQLiteEventStore = class {
4609
4765
  * Rebuild FTS index from existing events
4610
4766
  * Call this once after upgrading to FTS5
4611
4767
  */
4612
- async rebuildFtsIndex() {
4613
- await this.initialize();
4614
- const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4615
- const totalEvents = countRow?.count ?? 0;
4768
+ /**
4769
+ * Drop and recreate the events_fts virtual table + sync triggers, repopulating
4770
+ * from the current events table. Recreating the table (instead of issuing
4771
+ * DELETEs against it) sidesteps a quirk where older migrated FTS5 tables fail
4772
+ * with `no such column: T.event_id` on synthetic deletes. Safe to call inside
4773
+ * a transaction (pure DDL + INSERT...SELECT, no transaction control).
4774
+ */
4775
+ recreateEventsFtsTable() {
4616
4776
  sqliteExec(this.db, `
4617
4777
  DROP TRIGGER IF EXISTS events_fts_insert;
4618
4778
  DROP TRIGGER IF EXISTS events_fts_delete;
@@ -4641,6 +4801,12 @@ var SQLiteEventStore = class {
4641
4801
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4642
4802
  END;
4643
4803
  `);
4804
+ }
4805
+ async rebuildFtsIndex() {
4806
+ await this.initialize();
4807
+ const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4808
+ const totalEvents = countRow?.count ?? 0;
4809
+ this.recreateEventsFtsTable();
4644
4810
  return totalEvents;
4645
4811
  }
4646
4812
  /**
@@ -4658,6 +4824,35 @@ var SQLiteEventStore = class {
4658
4824
  return false;
4659
4825
  }
4660
4826
  }
4827
+ hasTable(tableName) {
4828
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) return false;
4829
+ try {
4830
+ const rows = sqliteAll(
4831
+ this.db,
4832
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
4833
+ [tableName]
4834
+ );
4835
+ return rows.length > 0;
4836
+ } catch {
4837
+ return false;
4838
+ }
4839
+ }
4840
+ /**
4841
+ * Add a column only when its table exists and the column is missing.
4842
+ *
4843
+ * Unlike a blind `try { ALTER } catch {}`, this distinguishes "already
4844
+ * migrated" / "table not present yet" (both safely skipped) from a genuine
4845
+ * failure (disk full, corruption), which is allowed to surface instead of
4846
+ * being silently swallowed as if the migration had succeeded.
4847
+ */
4848
+ addColumnIfMissing(table, column, definition) {
4849
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
4850
+ throw new Error(`Invalid identifier in migration: ${table}.${column}`);
4851
+ }
4852
+ if (!this.hasTable(table)) return;
4853
+ if (this.hasTableColumn(table, column)) return;
4854
+ sqliteExec(this.db, `ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
4855
+ }
4661
4856
  async recordRetrievalTrace(input) {
4662
4857
  await this.initialize();
4663
4858
  const traceId = randomUUID6();
@@ -4743,6 +4938,45 @@ var SQLiteEventStore = class {
4743
4938
  FROM retrieval_traces`,
4744
4939
  []
4745
4940
  );
4941
+ const strategyColumnSql = this.hasTableColumn("retrieval_traces", "strategy") ? "COALESCE(NULLIF(TRIM(strategy), ''), 'unknown')" : "'unknown'";
4942
+ const strategyRows = sqliteAll(
4943
+ this.db,
4944
+ `SELECT
4945
+ ${strategyColumnSql} as strategy,
4946
+ COUNT(*) as total_queries,
4947
+ SUM(CASE WHEN selected_count > 0 THEN 1 ELSE 0 END) as queries_with_selection,
4948
+ SUM(CASE WHEN ${rewrittenQueryRewriteKindSql} THEN 1 ELSE 0 END) as rewritten_queries,
4949
+ SUM(candidate_count) as total_candidate_count,
4950
+ SUM(selected_count) as total_selected_count,
4951
+ AVG(candidate_count) as avg_candidate_count,
4952
+ AVG(selected_count) as avg_selected_count,
4953
+ CASE
4954
+ WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
4955
+ ELSE 0
4956
+ END as selection_rate
4957
+ FROM retrieval_traces
4958
+ GROUP BY ${strategyColumnSql}
4959
+ ORDER BY total_queries DESC, strategy ASC`,
4960
+ []
4961
+ );
4962
+ const strategyBreakdown = strategyRows.map((strategyRow) => {
4963
+ const strategyTotalQueries = Number(strategyRow.total_queries || 0);
4964
+ const strategyQueriesWithSelection = Number(strategyRow.queries_with_selection || 0);
4965
+ const strategyRewrittenQueries = Number(strategyRow.rewritten_queries || 0);
4966
+ return {
4967
+ strategy: String(strategyRow.strategy || "unknown"),
4968
+ totalQueries: strategyTotalQueries,
4969
+ queriesWithSelection: strategyQueriesWithSelection,
4970
+ rewrittenQueries: strategyRewrittenQueries,
4971
+ rewriteRate: strategyTotalQueries > 0 ? strategyRewrittenQueries / strategyTotalQueries : 0,
4972
+ totalCandidateCount: Number(strategyRow.total_candidate_count || 0),
4973
+ totalSelectedCount: Number(strategyRow.total_selected_count || 0),
4974
+ avgCandidateCount: Number(strategyRow.avg_candidate_count || 0),
4975
+ avgSelectedCount: Number(strategyRow.avg_selected_count || 0),
4976
+ selectionRate: Number(strategyRow.selection_rate || 0),
4977
+ queryYieldRate: strategyTotalQueries > 0 ? strategyQueriesWithSelection / strategyTotalQueries : 0
4978
+ };
4979
+ });
4746
4980
  const totalQueries = Number(row?.total_queries || 0);
4747
4981
  const rewrittenQueries = Number(row?.rewritten_queries || 0);
4748
4982
  const rawQueries = Math.max(0, totalQueries - rewrittenQueries);
@@ -4760,7 +4994,8 @@ var SQLiteEventStore = class {
4760
4994
  rewrittenSelectionRate: rewrittenQueries > 0 ? rewrittenQueriesWithSelection / rewrittenQueries : 0,
4761
4995
  rawSelectionRate: rawQueries > 0 ? rawQueriesWithSelection / rawQueries : 0,
4762
4996
  avgSelectedCountForRewrittenQueries: Number(row?.avg_selected_count_for_rewritten_queries || 0),
4763
- avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0)
4997
+ avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0),
4998
+ strategyBreakdown
4764
4999
  };
4765
5000
  } catch (err) {
4766
5001
  if (err?.message?.includes("no such table")) {
@@ -4776,7 +5011,8 @@ var SQLiteEventStore = class {
4776
5011
  rewrittenSelectionRate: 0,
4777
5012
  rawSelectionRate: 0,
4778
5013
  avgSelectedCountForRewrittenQueries: 0,
4779
- avgSelectedCountForRawQueries: 0
5014
+ avgSelectedCountForRawQueries: 0,
5015
+ strategyBreakdown: []
4780
5016
  };
4781
5017
  }
4782
5018
  throw err;
@@ -4893,38 +5129,19 @@ var SQLiteEventStore = class {
4893
5129
  if (events.length === 0) return 0;
4894
5130
  const eventIds = events.map((e) => e.id);
4895
5131
  const placeholders = eventIds.map(() => "?").join(",");
4896
- const ftsTriggersDropped = [];
4897
- for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4898
- try {
5132
+ const relatedTables = ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox", "event_citations"].filter((table) => this.hasTable(table) && this.hasTableColumn(table, "event_id"));
5133
+ const runDelete = this.db.transaction(() => {
5134
+ for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4899
5135
  sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
4900
- ftsTriggersDropped.push(triggerName);
4901
- } catch {
4902
5136
  }
4903
- }
4904
- for (const table of ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox"]) {
4905
- try {
5137
+ for (const table of relatedTables) {
4906
5138
  sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders})`, eventIds);
4907
- } catch {
4908
- }
4909
- }
4910
- const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
4911
- if (ftsTriggersDropped.length > 0) {
4912
- try {
4913
- sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
4914
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
4915
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4916
- END`);
4917
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
4918
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4919
- END`);
4920
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
4921
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4922
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4923
- END`);
4924
- } catch {
4925
5139
  }
4926
- }
4927
- return result.changes || 0;
5140
+ const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
5141
+ this.recreateEventsFtsTable();
5142
+ return result.changes || 0;
5143
+ });
5144
+ return runDelete();
4928
5145
  }
4929
5146
  /**
4930
5147
  * Convert database row to MemoryEvent
@@ -6479,12 +6696,12 @@ var GraphPathService = class {
6479
6696
  }
6480
6697
  }
6481
6698
  }
6482
- const paths = Array.from(bestByTarget.entries()).map(([key, path14]) => ({
6699
+ const paths = Array.from(bestByTarget.entries()).map(([key, path16]) => ({
6483
6700
  target: graph.node(nodeFromKey(key)),
6484
- hops: path14.hops,
6485
- totalCost: path14.totalCost,
6486
- scoreContribution: path14.totalCost > 0 ? 1 / path14.totalCost : 0,
6487
- steps: path14.steps
6701
+ hops: path16.hops,
6702
+ totalCost: path16.totalCost,
6703
+ scoreContribution: path16.totalCost > 0 ? 1 / path16.totalCost : 0,
6704
+ steps: path16.steps
6488
6705
  })).sort((a, b) => b.scoreContribution - a.scoreContribution || a.hops - b.hops || a.target.name.localeCompare(b.target.name)).slice(0, maxResults);
6489
6706
  return { startNodes, effectiveMaxHops, paths };
6490
6707
  }
@@ -7257,7 +7474,7 @@ var SessionActorRepository = class {
7257
7474
  };
7258
7475
 
7259
7476
  // src/core/operations/perspective-observation-repository.ts
7260
- import { createHash as createHash3, randomUUID as randomUUID9 } from "crypto";
7477
+ import { createHash as createHash4, randomUUID as randomUUID9 } from "crypto";
7261
7478
  function projectHashToStorage4(projectHash) {
7262
7479
  return projectHash ?? "";
7263
7480
  }
@@ -7294,7 +7511,7 @@ function sanitizeStoredRecord(value) {
7294
7511
  return sanitizeGovernanceAuditValue(value);
7295
7512
  }
7296
7513
  function stableHash(value) {
7297
- return createHash3("sha256").update(JSON.stringify(value)).digest("hex");
7514
+ return createHash4("sha256").update(JSON.stringify(value)).digest("hex");
7298
7515
  }
7299
7516
  function evidenceHash(sourceEventIds, sourceObservationIds) {
7300
7517
  return stableHash({
@@ -7802,23 +8019,6 @@ function createPerspectiveDeriver(options) {
7802
8019
  return new PerspectiveDeriver(options);
7803
8020
  }
7804
8021
 
7805
- // src/core/citation-generator.ts
7806
- import { createHash as createHash4 } from "crypto";
7807
- var ID_LENGTH = 6;
7808
- var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
7809
- function generateCitationId(eventId) {
7810
- const hash = createHash4("sha256").update(eventId).digest();
7811
- let id = "";
7812
- for (let i = 0; i < ID_LENGTH; i++) {
7813
- id += CHARSET[hash[i] % CHARSET.length];
7814
- }
7815
- return id;
7816
- }
7817
- function parseCitationId(formatted) {
7818
- const match = formatted.match(/\[?mem:([A-Za-z0-9]{6})\]?/);
7819
- return match ? match[1] : null;
7820
- }
7821
-
7822
8022
  // src/core/vector-store.ts
7823
8023
  import * as lancedb from "@lancedb/lancedb";
7824
8024
  var VectorStore = class {
@@ -8426,6 +8626,30 @@ var MemoryQueryService = class {
8426
8626
  score: 1 - (r.rank - minRank) / rankRange
8427
8627
  })).filter((r) => !options?.minScore || r.score >= options.minScore);
8428
8628
  }
8629
+ async getEvent(id) {
8630
+ await this.initialize();
8631
+ return this.queryStore.getEvent(id);
8632
+ }
8633
+ async getEventByCitationId(citationId) {
8634
+ await this.initialize();
8635
+ return this.getMaintenanceStore("getEventByCitationId").getEventByCitationId(citationId);
8636
+ }
8637
+ async getEventsAfter(sinceIso) {
8638
+ await this.initialize();
8639
+ return this.getMaintenanceStore("getEventsAfter").getEventsAfter(sinceIso);
8640
+ }
8641
+ async getEventTypeCounts() {
8642
+ await this.initialize();
8643
+ return this.getMaintenanceStore("getEventTypeCounts").getEventTypeCounts();
8644
+ }
8645
+ async getDistinctSessionCount() {
8646
+ await this.initialize();
8647
+ return this.getMaintenanceStore("getDistinctSessionCount").getDistinctSessionCount();
8648
+ }
8649
+ async getDailyEventCounts(sinceIso) {
8650
+ await this.initialize();
8651
+ return this.getMaintenanceStore("getDailyEventCounts").getDailyEventCounts(sinceIso);
8652
+ }
8429
8653
  async getSessionHistory(sessionId) {
8430
8654
  await this.initialize();
8431
8655
  return this.queryStore.getSessionEvents(sessionId);
@@ -9250,12 +9474,12 @@ var Retriever = class {
9250
9474
  direction: "both"
9251
9475
  });
9252
9476
  const titleByEntityId = new Map(startNodes.map((node) => [node.entityId, node.title]));
9253
- for (const path14 of expansion.paths) {
9254
- if (path14.target.type !== "event") continue;
9255
- const target = await this.eventStore.getEvent(path14.target.id);
9477
+ for (const path16 of expansion.paths) {
9478
+ if (path16.target.type !== "event") continue;
9479
+ const target = await this.eventStore.getEvent(path16.target.id);
9256
9480
  if (!target) continue;
9257
- const graphPath = toRetrievalGraphPathDebug(path14, titleByEntityId);
9258
- const score = graphPathScore(path14, opts.hopPenalty);
9481
+ const graphPath = toRetrievalGraphPathDebug(path16, titleByEntityId);
9482
+ const score = graphPathScore(path16, opts.hopPenalty);
9259
9483
  const existing = byId.get(target.id);
9260
9484
  const graphPaths = mergeGraphPaths(existing?.graphPaths ?? [], [graphPath]);
9261
9485
  const graphLane = {
@@ -9264,7 +9488,7 @@ var Retriever = class {
9264
9488
  score
9265
9489
  };
9266
9490
  const row = {
9267
- id: existing?.id ?? `graph-path-${path14.hops}-${target.id}`,
9491
+ id: existing?.id ?? `graph-path-${path16.hops}-${target.id}`,
9268
9492
  eventId: target.id,
9269
9493
  content: target.content,
9270
9494
  score: Math.max(existing?.score ?? 0, score),
@@ -9394,11 +9618,12 @@ var Retriever = class {
9394
9618
  if (!scope && projectScopeMode === "global" && facetFilters === null) return results;
9395
9619
  const normalizedIncludes = (scope?.contentIncludes || []).map((s) => s.toLowerCase());
9396
9620
  const filtered = [];
9621
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9397
9622
  for (const result of results) {
9398
9623
  if (scope?.sessionId && result.sessionId !== scope.sessionId) continue;
9399
9624
  if (scope?.sessionIdPrefix && !result.sessionId.startsWith(scope.sessionIdPrefix)) continue;
9400
9625
  if (scope?.eventTypes && scope.eventTypes.length > 0 && !scope.eventTypes.includes(result.eventType)) continue;
9401
- const event = await this.eventStore.getEvent(result.eventId);
9626
+ const event = eventMap.get(result.eventId);
9402
9627
  if (!event) continue;
9403
9628
  if (scope?.canonicalKeyPrefix && !event.canonicalKey.startsWith(scope.canonicalKeyPrefix)) continue;
9404
9629
  if (normalizedIncludes.length > 0) {
@@ -9507,10 +9732,30 @@ var Retriever = class {
9507
9732
  async retrieveRecent(limit = 100) {
9508
9733
  return this.eventStore.getRecentEvents(limit);
9509
9734
  }
9735
+ /**
9736
+ * Batch-load events by id into a Map in a single query, de-duplicating ids.
9737
+ * Replaces per-result getEvent round-trips in the scope/enrich hot paths.
9738
+ */
9739
+ async fetchEventsByIds(ids) {
9740
+ const map = /* @__PURE__ */ new Map();
9741
+ if (ids.length === 0) return map;
9742
+ const unique = [...new Set(ids)];
9743
+ if (typeof this.eventStore.getEvents === "function") {
9744
+ const events = await this.eventStore.getEvents(unique);
9745
+ for (const event of events) map.set(event.id, event);
9746
+ } else {
9747
+ const events = await Promise.all(unique.map((id) => this.eventStore.getEvent(id)));
9748
+ for (const event of events) {
9749
+ if (event) map.set(event.id, event);
9750
+ }
9751
+ }
9752
+ return map;
9753
+ }
9510
9754
  async enrichResults(results, options, query) {
9511
9755
  const memories = [];
9756
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9512
9757
  for (const result of results) {
9513
- const event = await this.eventStore.getEvent(result.eventId);
9758
+ const event = eventMap.get(result.eventId);
9514
9759
  if (!event) continue;
9515
9760
  if (this.graduation) {
9516
9761
  this.graduation.recordAccess(event.id, options.sessionId || "unknown", result.score);
@@ -9583,8 +9828,8 @@ _Context:_ ${sessionContext}`;
9583
9828
  }
9584
9829
  matchesMetadataScope(metadata, expected) {
9585
9830
  if (!metadata) return false;
9586
- return Object.entries(expected).every(([path14, value]) => {
9587
- const actual = path14.split(".").reduce((acc, key) => {
9831
+ return Object.entries(expected).every(([path16, value]) => {
9832
+ const actual = path16.split(".").reduce((acc, key) => {
9588
9833
  if (typeof acc !== "object" || acc === null) return void 0;
9589
9834
  return acc[key];
9590
9835
  }, metadata);
@@ -9635,22 +9880,22 @@ function uniqueEntityStartNodes(candidates) {
9635
9880
  }
9636
9881
  return nodes;
9637
9882
  }
9638
- function toRetrievalGraphPathDebug(path14, titleByEntityId) {
9639
- const firstStep = path14.steps[0];
9883
+ function toRetrievalGraphPathDebug(path16, titleByEntityId) {
9884
+ const firstStep = path16.steps[0];
9640
9885
  const startNode = firstStep?.direction === "incoming" ? firstStep.to : firstStep?.from;
9641
9886
  const startEntityId = startNode?.type === "entity" ? startNode.id : "";
9642
9887
  return {
9643
9888
  startEntityId,
9644
9889
  startEntityTitle: titleByEntityId.get(startEntityId) ?? startNode?.name,
9645
- targetId: path14.target.id,
9646
- targetType: path14.target.type,
9647
- hops: path14.hops,
9648
- relationPath: path14.steps.map((step) => step.relationType)
9890
+ targetId: path16.target.id,
9891
+ targetType: path16.target.type,
9892
+ hops: path16.hops,
9893
+ relationPath: path16.steps.map((step) => step.relationType)
9649
9894
  };
9650
9895
  }
9651
- function graphPathScore(path14, hopPenalty) {
9652
- const base = Math.min(0.95, Math.max(0, path14.scoreContribution));
9653
- return Math.max(0.05, base - hopPenalty * Math.max(0, path14.hops - 1));
9896
+ function graphPathScore(path16, hopPenalty) {
9897
+ const base = Math.min(0.95, Math.max(0, path16.scoreContribution));
9898
+ return Math.max(0.05, base - hopPenalty * Math.max(0, path16.hops - 1));
9654
9899
  }
9655
9900
  function clampGraphHops(maxHops) {
9656
9901
  if (!Number.isFinite(maxHops)) return 2;
@@ -9658,14 +9903,14 @@ function clampGraphHops(maxHops) {
9658
9903
  }
9659
9904
  function mergeGraphPaths(existing, incoming) {
9660
9905
  const byKey = /* @__PURE__ */ new Map();
9661
- for (const path14 of [...existing, ...incoming]) {
9662
- const key = [path14.startEntityId, path14.targetType, path14.targetId, path14.hops, ...path14.relationPath].join("\0");
9663
- if (!byKey.has(key)) byKey.set(key, path14);
9906
+ for (const path16 of [...existing, ...incoming]) {
9907
+ const key = [path16.startEntityId, path16.targetType, path16.targetId, path16.hops, ...path16.relationPath].join("\0");
9908
+ if (!byKey.has(key)) byKey.set(key, path16);
9664
9909
  }
9665
9910
  return [...byKey.values()].sort((a, b) => a.hops - b.hops || compareStable(graphPathSignature(a), graphPathSignature(b))).slice(0, 3);
9666
9911
  }
9667
- function graphPathSignature(path14) {
9668
- return [path14.startEntityId, path14.targetType, path14.targetId, path14.hops, ...path14.relationPath].join("|");
9912
+ function graphPathSignature(path16) {
9913
+ return [path16.startEntityId, path16.targetType, path16.targetId, path16.hops, ...path16.relationPath].join("|");
9669
9914
  }
9670
9915
  function compareStable(a, b) {
9671
9916
  if (a < b) return -1;
@@ -12239,18 +12484,46 @@ var MemoryService = class {
12239
12484
  return this.queryService.rebuildFtsIndex();
12240
12485
  }
12241
12486
  /**
12242
- * Get session history
12487
+ * Get a single event by id (indexed lookup).
12243
12488
  */
12244
- async getSessionHistory(sessionId) {
12245
- return this.queryService.getSessionHistory(sessionId);
12489
+ async getEvent(id) {
12490
+ return this.queryService.getEvent(id);
12246
12491
  }
12247
12492
  /**
12248
- * Get recent events
12493
+ * Resolve an event from its citation id via the reverse index.
12249
12494
  */
12250
- async getRecentEvents(limit = 100) {
12251
- return this.queryService.getRecentEvents(limit);
12495
+ async getEventByCitationId(citationId) {
12496
+ return this.queryService.getEventByCitationId(citationId);
12252
12497
  }
12253
- /**
12498
+ /** All events at/after an ISO timestamp (ascending, uncapped). */
12499
+ async getEventsAfter(sinceIso) {
12500
+ return this.queryService.getEventsAfter(sinceIso);
12501
+ }
12502
+ /** Aggregate event counts by type (SQL GROUP BY). */
12503
+ async getEventTypeCounts() {
12504
+ return this.queryService.getEventTypeCounts();
12505
+ }
12506
+ /** Count distinct sessions (SQL). */
12507
+ async getDistinctSessionCount() {
12508
+ return this.queryService.getDistinctSessionCount();
12509
+ }
12510
+ /** Per-day event counts with type breakdown since an ISO timestamp (SQL). */
12511
+ async getDailyEventCounts(sinceIso) {
12512
+ return this.queryService.getDailyEventCounts(sinceIso);
12513
+ }
12514
+ /**
12515
+ * Get session history
12516
+ */
12517
+ async getSessionHistory(sessionId) {
12518
+ return this.queryService.getSessionHistory(sessionId);
12519
+ }
12520
+ /**
12521
+ * Get recent events
12522
+ */
12523
+ async getRecentEvents(limit = 100) {
12524
+ return this.queryService.getRecentEvents(limit);
12525
+ }
12526
+ /**
12254
12527
  * Get memory statistics
12255
12528
  */
12256
12529
  async getOutboxStats(options) {
@@ -12562,6 +12835,11 @@ var getLightweightMemoryServiceForProject = defaultRegistry.getLightweightMemory
12562
12835
  var createMemoryService = defaultRegistry.createMemoryService;
12563
12836
 
12564
12837
  // src/apps/server/api/utils.ts
12838
+ function jsonError(c, error, options = {}) {
12839
+ const status = options.status ?? 500;
12840
+ console.error(`[api] ${c.req.method} ${c.req.path} failed:`, error);
12841
+ return c.json({ error: options.message ?? "Internal server error" }, status);
12842
+ }
12565
12843
  function getServiceFromQuery(c) {
12566
12844
  const project = c.req.query("project") || c.req.query("projectId");
12567
12845
  if (project) {
@@ -12673,7 +12951,7 @@ sessionsRouter.get("/", async (c) => {
12673
12951
  hasMore: end < total
12674
12952
  });
12675
12953
  } catch (error) {
12676
- return c.json({ error: error.message }, 500);
12954
+ return jsonError(c, error);
12677
12955
  } finally {
12678
12956
  await memoryService.shutdown();
12679
12957
  }
@@ -12709,7 +12987,7 @@ sessionsRouter.get("/:id", async (c) => {
12709
12987
  stats: eventsByType
12710
12988
  });
12711
12989
  } catch (error) {
12712
- return c.json({ error: error.message }, 500);
12990
+ return jsonError(c, error);
12713
12991
  } finally {
12714
12992
  await memoryService.shutdown();
12715
12993
  }
@@ -12775,7 +13053,7 @@ eventsRouter.get("/", async (c) => {
12775
13053
  hasMore: offset + limit < total
12776
13054
  });
12777
13055
  } catch (error) {
12778
- return c.json({ error: error.message }, 500);
13056
+ return jsonError(c, error);
12779
13057
  } finally {
12780
13058
  await memoryService.shutdown();
12781
13059
  }
@@ -12785,12 +13063,11 @@ eventsRouter.get("/:id", async (c) => {
12785
13063
  const memoryService = getLightweightServiceFromQuery(c);
12786
13064
  try {
12787
13065
  await memoryService.initialize();
12788
- const recentEvents = await memoryService.getRecentEvents(1e4);
12789
- const event = recentEvents.find((e) => e.id === id);
13066
+ const event = await memoryService.getEvent(id);
12790
13067
  if (!event) {
12791
13068
  return c.json({ error: "Event not found" }, 404);
12792
13069
  }
12793
- const sessionEvents = recentEvents.filter((e) => e.sessionId === event.sessionId).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
13070
+ const sessionEvents = (await memoryService.getSessionHistory(event.sessionId)).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
12794
13071
  const eventIndex = sessionEvents.findIndex((e) => e.id === id);
12795
13072
  const start = Math.max(0, eventIndex - 2);
12796
13073
  const end = Math.min(sessionEvents.length, eventIndex + 3);
@@ -12812,7 +13089,7 @@ eventsRouter.get("/:id", async (c) => {
12812
13089
  }))
12813
13090
  });
12814
13091
  } catch (error) {
12815
- return c.json({ error: error.message }, 500);
13092
+ return jsonError(c, error);
12816
13093
  } finally {
12817
13094
  await memoryService.shutdown();
12818
13095
  }
@@ -12859,7 +13136,7 @@ searchRouter.post("/", async (c) => {
12859
13136
  }
12860
13137
  });
12861
13138
  } catch (error) {
12862
- return c.json({ error: error.message }, 500);
13139
+ return jsonError(c, error);
12863
13140
  } finally {
12864
13141
  await memoryService.shutdown();
12865
13142
  }
@@ -12902,7 +13179,7 @@ searchRouter.post("/disclosure", async (c) => {
12902
13179
  throw error;
12903
13180
  }
12904
13181
  } catch (error) {
12905
- return c.json({ error: error.message }, 500);
13182
+ return jsonError(c, error);
12906
13183
  } finally {
12907
13184
  await memoryService?.shutdown();
12908
13185
  }
@@ -12922,7 +13199,7 @@ searchRouter.get("/disclosure/:resultId/expand", async (c) => {
12922
13199
  }
12923
13200
  return c.json(result);
12924
13201
  } catch (error) {
12925
- return c.json({ error: error.message }, 500);
13202
+ return jsonError(c, error);
12926
13203
  } finally {
12927
13204
  await memoryService.shutdown();
12928
13205
  }
@@ -12937,7 +13214,7 @@ searchRouter.get("/disclosure/:resultId/source", async (c) => {
12937
13214
  }
12938
13215
  return c.json(result);
12939
13216
  } catch (error) {
12940
- return c.json({ error: error.message }, 500);
13217
+ return jsonError(c, error);
12941
13218
  } finally {
12942
13219
  await memoryService.shutdown();
12943
13220
  }
@@ -12966,7 +13243,7 @@ searchRouter.get("/", async (c) => {
12966
13243
  }
12967
13244
  });
12968
13245
  } catch (error) {
12969
- return c.json({ error: error.message }, 500);
13246
+ return jsonError(c, error);
12970
13247
  } finally {
12971
13248
  await memoryService.shutdown();
12972
13249
  }
@@ -12974,9 +13251,89 @@ searchRouter.get("/", async (c) => {
12974
13251
 
12975
13252
  // src/apps/server/api/stats.ts
12976
13253
  import { Hono as Hono4 } from "hono";
12977
- import * as fs9 from "fs";
13254
+ import * as fs10 from "fs";
12978
13255
  import * as os6 from "os";
13256
+ import * as path12 from "path";
13257
+
13258
+ // src/apps/server/api/stats-metrics.ts
13259
+ import * as fs9 from "fs";
12979
13260
  import * as path11 from "path";
13261
+ var DEFAULT_KPI_THRESHOLDS = {
13262
+ usefulRecallRateMin: 0.45,
13263
+ reworkRateMax: 0.25,
13264
+ postChangeFailureRateMax: 0.2,
13265
+ avgCompletionTurnsMax: 12,
13266
+ memoryHitRateMin: 0.35
13267
+ };
13268
+ function loadKpiThresholds() {
13269
+ try {
13270
+ const filePath = path11.resolve(process.cwd(), "config", "kpi-thresholds.json");
13271
+ if (!fs9.existsSync(filePath)) return DEFAULT_KPI_THRESHOLDS;
13272
+ const parsed = JSON.parse(fs9.readFileSync(filePath, "utf-8"));
13273
+ return {
13274
+ usefulRecallRateMin: Number(parsed.usefulRecallRateMin ?? DEFAULT_KPI_THRESHOLDS.usefulRecallRateMin),
13275
+ reworkRateMax: Number(parsed.reworkRateMax ?? DEFAULT_KPI_THRESHOLDS.reworkRateMax),
13276
+ postChangeFailureRateMax: Number(parsed.postChangeFailureRateMax ?? DEFAULT_KPI_THRESHOLDS.postChangeFailureRateMax),
13277
+ avgCompletionTurnsMax: Number(parsed.avgCompletionTurnsMax ?? DEFAULT_KPI_THRESHOLDS.avgCompletionTurnsMax),
13278
+ memoryHitRateMin: Number(parsed.memoryHitRateMin ?? DEFAULT_KPI_THRESHOLDS.memoryHitRateMin)
13279
+ };
13280
+ } catch {
13281
+ return DEFAULT_KPI_THRESHOLDS;
13282
+ }
13283
+ }
13284
+ function windowToMs(window) {
13285
+ if (window === "24h") return 24 * 60 * 60 * 1e3;
13286
+ if (window === "7d") return 7 * 24 * 60 * 60 * 1e3;
13287
+ return 30 * 24 * 60 * 60 * 1e3;
13288
+ }
13289
+ function inWindow(e, now, window) {
13290
+ return now - e.timestamp.getTime() <= windowToMs(window);
13291
+ }
13292
+ function isEditToolName(name) {
13293
+ return ["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name);
13294
+ }
13295
+ function parseToolPayload(e) {
13296
+ if (e.eventType !== "tool_observation") return null;
13297
+ try {
13298
+ const payload = JSON.parse(e.content);
13299
+ return {
13300
+ toolName: payload?.toolName,
13301
+ success: payload?.success,
13302
+ filePath: payload?.metadata?.filePath,
13303
+ command: payload?.metadata?.command
13304
+ };
13305
+ } catch {
13306
+ return {
13307
+ toolName: e.metadata?.toolName,
13308
+ success: e.metadata?.success,
13309
+ filePath: e.metadata?.filePath,
13310
+ command: e.metadata?.command
13311
+ };
13312
+ }
13313
+ }
13314
+ function isTestLikeCommand(command) {
13315
+ if (!command) return false;
13316
+ return /(test|jest|vitest|pytest|go test|cargo test|lint|eslint|build|tsc)/i.test(command);
13317
+ }
13318
+ function safeRatio(num, den) {
13319
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) return 0;
13320
+ return num / den;
13321
+ }
13322
+ function round(value, digits = 4) {
13323
+ const factor = 10 ** digits;
13324
+ return Math.round(value * factor) / factor;
13325
+ }
13326
+ function computeSessionTurnCount(sessionEvents) {
13327
+ const turnIds = /* @__PURE__ */ new Set();
13328
+ for (const e of sessionEvents) {
13329
+ const turnId = e.metadata?.turnId;
13330
+ if (typeof turnId === "string" && turnId.length > 0) turnIds.add(turnId);
13331
+ }
13332
+ if (turnIds.size > 0) return turnIds.size;
13333
+ return sessionEvents.filter((e) => e.eventType === "user_prompt").length;
13334
+ }
13335
+
13336
+ // src/apps/server/api/stats.ts
12980
13337
  var statsRouter = new Hono4();
12981
13338
  var OPERATION_STATS_TABLES = [
12982
13339
  "memory_facets",
@@ -13003,7 +13360,7 @@ function operationsStatsHomeDir() {
13003
13360
  }
13004
13361
  function projectStoragePathForOperationsStats(projectOrHash) {
13005
13362
  const projectHash = /^[a-f0-9]{8}$/.test(projectOrHash) ? projectOrHash : hashProjectPath(projectOrHash);
13006
- return path11.join(operationsStatsHomeDir(), ".claude-code", "memory", "projects", projectHash);
13363
+ return path12.join(operationsStatsHomeDir(), ".claude-code", "memory", "projects", projectHash);
13007
13364
  }
13008
13365
  function getOperationsStatsContext(project) {
13009
13366
  if (project && project.trim().length > 0) {
@@ -13013,13 +13370,13 @@ function getOperationsStatsContext(project) {
13013
13370
  return {
13014
13371
  projectHash,
13015
13372
  storagePath: storagePath2,
13016
- dbPath: path11.join(storagePath2, "events.sqlite")
13373
+ dbPath: path12.join(storagePath2, "events.sqlite")
13017
13374
  };
13018
13375
  }
13019
- const storagePath = path11.join(operationsStatsHomeDir(), ".claude-code", "memory");
13376
+ const storagePath = path12.join(operationsStatsHomeDir(), ".claude-code", "memory");
13020
13377
  return {
13021
13378
  storagePath,
13022
- dbPath: path11.join(storagePath, "events.sqlite")
13379
+ dbPath: path12.join(storagePath, "events.sqlite")
13023
13380
  };
13024
13381
  }
13025
13382
  function countRowValue(db, sql, params = []) {
@@ -13037,6 +13394,23 @@ function sanitizeAggregateLabel(value) {
13037
13394
  const trimmed = sanitized.trim();
13038
13395
  return trimmed.length > 0 ? trimmed.slice(0, 96) : "unknown";
13039
13396
  }
13397
+ var SAFE_RETRIEVAL_TRACE_STRATEGIES = /* @__PURE__ */ new Set([
13398
+ "auto",
13399
+ "deep",
13400
+ "fast",
13401
+ "hybrid",
13402
+ "keyword",
13403
+ "semantic",
13404
+ "mcp-context-pack",
13405
+ "session-start-hook",
13406
+ "unknown"
13407
+ ]);
13408
+ function normalizeRetrievalTraceStrategy(value) {
13409
+ const label = sanitizeAggregateLabel(value);
13410
+ if (label === "[REDACTED]") return "unknown";
13411
+ const normalized = label.trim().toLowerCase();
13412
+ return SAFE_RETRIEVAL_TRACE_STRATEGIES.has(normalized) ? normalized : "unknown";
13413
+ }
13040
13414
  function emptyOperationsStatsPayload(context, databaseExists, missingTables, windowDays) {
13041
13415
  return {
13042
13416
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -13450,80 +13824,6 @@ function buildLessonConfidenceBuckets(db, projectHash) {
13450
13824
  }).length
13451
13825
  }));
13452
13826
  }
13453
- var DEFAULT_KPI_THRESHOLDS = {
13454
- usefulRecallRateMin: 0.45,
13455
- reworkRateMax: 0.25,
13456
- postChangeFailureRateMax: 0.2,
13457
- avgCompletionTurnsMax: 12,
13458
- memoryHitRateMin: 0.35
13459
- };
13460
- function loadKpiThresholds() {
13461
- try {
13462
- const filePath = path11.resolve(process.cwd(), "config", "kpi-thresholds.json");
13463
- if (!fs9.existsSync(filePath)) return DEFAULT_KPI_THRESHOLDS;
13464
- const parsed = JSON.parse(fs9.readFileSync(filePath, "utf-8"));
13465
- return {
13466
- usefulRecallRateMin: Number(parsed.usefulRecallRateMin ?? DEFAULT_KPI_THRESHOLDS.usefulRecallRateMin),
13467
- reworkRateMax: Number(parsed.reworkRateMax ?? DEFAULT_KPI_THRESHOLDS.reworkRateMax),
13468
- postChangeFailureRateMax: Number(parsed.postChangeFailureRateMax ?? DEFAULT_KPI_THRESHOLDS.postChangeFailureRateMax),
13469
- avgCompletionTurnsMax: Number(parsed.avgCompletionTurnsMax ?? DEFAULT_KPI_THRESHOLDS.avgCompletionTurnsMax),
13470
- memoryHitRateMin: Number(parsed.memoryHitRateMin ?? DEFAULT_KPI_THRESHOLDS.memoryHitRateMin)
13471
- };
13472
- } catch {
13473
- return DEFAULT_KPI_THRESHOLDS;
13474
- }
13475
- }
13476
- function windowToMs(window) {
13477
- if (window === "24h") return 24 * 60 * 60 * 1e3;
13478
- if (window === "7d") return 7 * 24 * 60 * 60 * 1e3;
13479
- return 30 * 24 * 60 * 60 * 1e3;
13480
- }
13481
- function inWindow(e, now, window) {
13482
- return now - e.timestamp.getTime() <= windowToMs(window);
13483
- }
13484
- function isEditToolName(name) {
13485
- return ["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name);
13486
- }
13487
- function parseToolPayload(e) {
13488
- if (e.eventType !== "tool_observation") return null;
13489
- try {
13490
- const payload = JSON.parse(e.content);
13491
- return {
13492
- toolName: payload?.toolName,
13493
- success: payload?.success,
13494
- filePath: payload?.metadata?.filePath,
13495
- command: payload?.metadata?.command
13496
- };
13497
- } catch {
13498
- return {
13499
- toolName: e.metadata?.toolName,
13500
- success: e.metadata?.success,
13501
- filePath: e.metadata?.filePath,
13502
- command: e.metadata?.command
13503
- };
13504
- }
13505
- }
13506
- function isTestLikeCommand(command) {
13507
- if (!command) return false;
13508
- return /(test|jest|vitest|pytest|go test|cargo test|lint|eslint|build|tsc)/i.test(command);
13509
- }
13510
- function safeRatio(num, den) {
13511
- if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) return 0;
13512
- return num / den;
13513
- }
13514
- function round(value, digits = 4) {
13515
- const factor = 10 ** digits;
13516
- return Math.round(value * factor) / factor;
13517
- }
13518
- function computeSessionTurnCount(sessionEvents) {
13519
- const turnIds = /* @__PURE__ */ new Set();
13520
- for (const e of sessionEvents) {
13521
- const turnId = e.metadata?.turnId;
13522
- if (typeof turnId === "string" && turnId.length > 0) turnIds.add(turnId);
13523
- }
13524
- if (turnIds.size > 0) return turnIds.size;
13525
- return sessionEvents.filter((e) => e.eventType === "user_prompt").length;
13526
- }
13527
13827
  function normalizeQueryRewriteKind2(value) {
13528
13828
  const normalized = (value || "").trim().toLowerCase();
13529
13829
  if (normalized === "follow-up-context" || normalized === "intent-rewrite") return normalized;
@@ -13551,6 +13851,63 @@ function getTraceSelectedCount(trace) {
13551
13851
  function getTraceCandidateCount(trace) {
13552
13852
  return Number(trace.candidateCount ?? trace.candidateEventIds?.length ?? trace.candidateDetails?.length ?? 0);
13553
13853
  }
13854
+ function normalizeStatsNumber(value) {
13855
+ const numberValue = Number(value || 0);
13856
+ return Number.isFinite(numberValue) ? Math.max(0, numberValue) : 0;
13857
+ }
13858
+ function sanitizeRetrievalTraceStrategyBreakdown(value) {
13859
+ if (!Array.isArray(value)) return [];
13860
+ const byStrategy = /* @__PURE__ */ new Map();
13861
+ for (const item of value) {
13862
+ const row = item && typeof item === "object" ? item : {};
13863
+ const strategy = normalizeRetrievalTraceStrategy(row.strategy ?? "unknown");
13864
+ const bucket = byStrategy.get(strategy) ?? {
13865
+ strategy,
13866
+ totalQueries: 0,
13867
+ queriesWithSelection: 0,
13868
+ rewrittenQueries: 0,
13869
+ totalCandidateCount: 0,
13870
+ totalSelectedCount: 0
13871
+ };
13872
+ bucket.totalQueries += normalizeStatsNumber(row.totalQueries);
13873
+ bucket.queriesWithSelection += normalizeStatsNumber(row.queriesWithSelection);
13874
+ bucket.rewrittenQueries += normalizeStatsNumber(row.rewrittenQueries);
13875
+ bucket.totalCandidateCount += normalizeStatsNumber(row.totalCandidateCount);
13876
+ bucket.totalSelectedCount += normalizeStatsNumber(row.totalSelectedCount);
13877
+ byStrategy.set(strategy, bucket);
13878
+ }
13879
+ return Array.from(byStrategy.values()).map((row) => ({
13880
+ strategy: row.strategy,
13881
+ totalQueries: row.totalQueries,
13882
+ queriesWithSelection: row.queriesWithSelection,
13883
+ rewrittenQueries: row.rewrittenQueries,
13884
+ rewriteRate: normalizeMetric(safeRatio(row.rewrittenQueries, row.totalQueries)),
13885
+ totalCandidateCount: row.totalCandidateCount,
13886
+ totalSelectedCount: row.totalSelectedCount,
13887
+ avgCandidateCount: safeRatio(row.totalCandidateCount, row.totalQueries),
13888
+ avgSelectedCount: safeRatio(row.totalSelectedCount, row.totalQueries),
13889
+ selectionRate: normalizeMetric(safeRatio(row.totalSelectedCount, row.totalCandidateCount)),
13890
+ queryYieldRate: normalizeMetric(safeRatio(row.queriesWithSelection, row.totalQueries))
13891
+ }));
13892
+ }
13893
+ function sanitizeRetrievalTraceStats(stats) {
13894
+ const s = stats && typeof stats === "object" ? stats : {};
13895
+ return {
13896
+ totalQueries: Number(s.totalQueries || 0),
13897
+ avgCandidateCount: Number(s.avgCandidateCount || 0),
13898
+ avgSelectedCount: Number(s.avgSelectedCount || 0),
13899
+ selectionRate: normalizeMetric(s.selectionRate),
13900
+ rewrittenQueries: Number(s.rewrittenQueries || 0),
13901
+ rewriteRate: normalizeMetric(s.rewriteRate),
13902
+ rewrittenQueriesWithSelection: Number(s.rewrittenQueriesWithSelection || 0),
13903
+ rawQueriesWithSelection: Number(s.rawQueriesWithSelection || 0),
13904
+ rewrittenSelectionRate: normalizeMetric(s.rewrittenSelectionRate),
13905
+ rawSelectionRate: normalizeMetric(s.rawSelectionRate),
13906
+ avgSelectedCountForRewrittenQueries: Number(s.avgSelectedCountForRewrittenQueries || 0),
13907
+ avgSelectedCountForRawQueries: Number(s.avgSelectedCountForRawQueries || 0),
13908
+ strategyBreakdown: sanitizeRetrievalTraceStrategyBreakdown(s.strategyBreakdown)
13909
+ };
13910
+ }
13554
13911
  function makeRetrievalReviewItem(trace) {
13555
13912
  const candidateCount = getTraceCandidateCount(trace);
13556
13913
  const selectedCount = getTraceSelectedCount(trace);
@@ -13604,7 +13961,7 @@ function makeRetrievalReviewItem(trace) {
13604
13961
  action,
13605
13962
  queryRewriteKind,
13606
13963
  rewritten,
13607
- strategy: trace.strategy || null,
13964
+ strategy: normalizeRetrievalTraceStrategy(trace.strategy ?? "unknown"),
13608
13965
  candidateCount,
13609
13966
  selectedCount,
13610
13967
  candidateEventIds: (trace.candidateEventIds || []).slice(0, 5),
@@ -14031,7 +14388,7 @@ statsRouter.get("/levels/:level", async (c) => {
14031
14388
  hasMore: events.length === limit
14032
14389
  });
14033
14390
  } catch (error) {
14034
- return c.json({ error: error.message }, 500);
14391
+ return jsonError(c, error);
14035
14392
  } finally {
14036
14393
  await memoryService.shutdown();
14037
14394
  }
@@ -14040,7 +14397,7 @@ statsRouter.get("/operations", async (c) => {
14040
14397
  const context = getOperationsStatsContext(c.req.query("project") || c.req.query("projectId"));
14041
14398
  const windowDays = parseStatsLimit(c.req.query("windowDays"), 30, 365);
14042
14399
  const topFacetValues = parseStatsLimit(c.req.query("topFacetValues"), 5, 25);
14043
- const databaseExists = fs9.existsSync(context.dbPath);
14400
+ const databaseExists = fs10.existsSync(context.dbPath);
14044
14401
  if (!databaseExists) {
14045
14402
  return c.json(emptyOperationsStatsPayload(context, false, [...OPERATION_STATS_TABLES], windowDays));
14046
14403
  }
@@ -14128,7 +14485,7 @@ statsRouter.get("/perspective", async (c) => {
14128
14485
  const windowDays = parseStatsLimit(c.req.query("windowDays"), 30, 365);
14129
14486
  const contradictionLimit = parseStatsLimit(c.req.query("contradictionLimit"), 10, 50);
14130
14487
  const graphLimit = parseStatsLimit(c.req.query("graphLimit"), 10, 50);
14131
- const databaseExists = fs9.existsSync(context.dbPath);
14488
+ const databaseExists = fs10.existsSync(context.dbPath);
14132
14489
  if (!databaseExists) {
14133
14490
  return c.json(emptyPerspectiveStatsPayload(context, false, [...PERSPECTIVE_STATS_TABLES], windowDays));
14134
14491
  }
@@ -14204,33 +14561,29 @@ statsRouter.get("/", async (c) => {
14204
14561
  const memoryService = getLightweightServiceFromQuery(c);
14205
14562
  try {
14206
14563
  await memoryService.initialize();
14207
- const stats = await memoryService.getStats();
14208
- const recentEvents = await memoryService.getRecentEvents(1e4);
14209
- const eventsByType = recentEvents.reduce((acc, e) => {
14210
- acc[e.eventType] = (acc[e.eventType] || 0) + 1;
14211
- return acc;
14212
- }, {});
14213
- const uniqueSessions = new Set(recentEvents.map((e) => e.sessionId));
14214
- const now = /* @__PURE__ */ new Date();
14215
- const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
14216
- const eventsByDay = recentEvents.filter((e) => e.timestamp >= sevenDaysAgo).reduce((acc, e) => {
14217
- const day = e.timestamp.toISOString().split("T")[0];
14218
- acc[day] = (acc[day] || 0) + 1;
14219
- return acc;
14220
- }, {});
14221
- const retrievalTrace = await memoryService.getRetrievalTraceStats();
14564
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3).toISOString();
14565
+ const [stats, typeCounts, sessionTotal, dailyCounts, rawTrace] = await Promise.all([
14566
+ memoryService.getStats(),
14567
+ memoryService.getEventTypeCounts(),
14568
+ memoryService.getDistinctSessionCount(),
14569
+ memoryService.getDailyEventCounts(sevenDaysAgo),
14570
+ memoryService.getRetrievalTraceStats()
14571
+ ]);
14572
+ const eventsByType = Object.fromEntries(typeCounts.map((t) => [t.eventType, t.count]));
14573
+ const eventsByDay = Object.fromEntries(dailyCounts.map((d) => [d.day, d.total]));
14574
+ const retrievalTrace = sanitizeRetrievalTraceStats(rawTrace);
14222
14575
  return c.json({
14223
14576
  storage: {
14224
14577
  eventCount: stats.totalEvents,
14225
14578
  vectorCount: stats.vectorCount
14226
14579
  },
14227
14580
  sessions: {
14228
- total: uniqueSessions.size
14581
+ total: sessionTotal
14229
14582
  },
14230
14583
  eventsByType,
14231
14584
  activity: {
14232
14585
  daily: eventsByDay,
14233
- total7Days: recentEvents.filter((e) => e.timestamp >= sevenDaysAgo).length
14586
+ total7Days: dailyCounts.reduce((sum, d) => sum + d.total, 0)
14234
14587
  },
14235
14588
  memory: {
14236
14589
  heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
@@ -14240,7 +14593,7 @@ statsRouter.get("/", async (c) => {
14240
14593
  retrievalTrace
14241
14594
  });
14242
14595
  } catch (error) {
14243
- return c.json({ error: error.message }, 500);
14596
+ return jsonError(c, error);
14244
14597
  } finally {
14245
14598
  await memoryService.shutdown();
14246
14599
  }
@@ -14277,30 +14630,22 @@ statsRouter.get("/most-accessed", async (c) => {
14277
14630
  }
14278
14631
  });
14279
14632
  statsRouter.get("/timeline", async (c) => {
14280
- const days = parseInt(c.req.query("days") || "7", 10);
14633
+ const parsedDays = parseInt(c.req.query("days") || "7", 10);
14634
+ const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 7;
14281
14635
  const memoryService = getLightweightServiceFromQuery(c);
14282
14636
  try {
14283
14637
  await memoryService.initialize();
14284
- const recentEvents = await memoryService.getRecentEvents(1e4);
14285
- const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
14286
- const filteredEvents = recentEvents.filter((e) => e.timestamp >= cutoff);
14287
- const daily = filteredEvents.reduce((acc, e) => {
14288
- const day = e.timestamp.toISOString().split("T")[0];
14289
- if (!acc[day]) {
14290
- acc[day] = { date: day, total: 0, prompts: 0, responses: 0, tools: 0 };
14291
- }
14292
- acc[day].total++;
14293
- if (e.eventType === "user_prompt") acc[day].prompts++;
14294
- if (e.eventType === "agent_response") acc[day].responses++;
14295
- if (e.eventType === "tool_observation") acc[day].tools++;
14296
- return acc;
14297
- }, {});
14298
- return c.json({
14299
- days,
14300
- daily: Object.values(daily).sort((a, b) => a.date.localeCompare(b.date))
14301
- });
14638
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1e3).toISOString();
14639
+ const daily = (await memoryService.getDailyEventCounts(cutoff)).map((d) => ({
14640
+ date: d.day,
14641
+ total: d.total,
14642
+ prompts: d.prompts,
14643
+ responses: d.responses,
14644
+ tools: d.tools
14645
+ }));
14646
+ return c.json({ days, daily });
14302
14647
  } catch (error) {
14303
- return c.json({ error: error.message }, 500);
14648
+ return jsonError(c, error);
14304
14649
  } finally {
14305
14650
  await memoryService.shutdown();
14306
14651
  }
@@ -14343,16 +14688,14 @@ statsRouter.get("/usefulness", async (c) => {
14343
14688
  try {
14344
14689
  await memoryService.initialize();
14345
14690
  const now = Date.now();
14346
- const eventLimit = 2e4;
14347
14691
  const traceLimit = 5e3;
14348
14692
  const windowStart = new Date(now - windowToMs(window));
14349
14693
  const [events, helpfulness, traces] = await Promise.all([
14350
- memoryService.getRecentEvents(eventLimit),
14694
+ memoryService.getEventsAfter(windowStart.toISOString()),
14351
14695
  memoryService.getHelpfulnessStats(windowStart),
14352
14696
  memoryService.getRecentRetrievalTraces(traceLimit)
14353
14697
  ]);
14354
14698
  return c.json(computeMemoryUsefulnessSummary(events, helpfulness, traces, now, window, {
14355
- eventsLimit: eventLimit,
14356
14699
  tracesLimit: traceLimit
14357
14700
  }));
14358
14701
  } catch (error) {
@@ -14370,7 +14713,7 @@ statsRouter.get("/retrieval-traces", async (c) => {
14370
14713
  const traces = await memoryService.getRecentRetrievalTraces(limit);
14371
14714
  const traceStats = await memoryService.getRetrievalTraceStats();
14372
14715
  return c.json({
14373
- stats: traceStats,
14716
+ stats: sanitizeRetrievalTraceStats(traceStats),
14374
14717
  traces: traces.map((t) => {
14375
14718
  const queryRewriteKind = normalizeQueryRewriteKind2(t.queryRewriteKind);
14376
14719
  return {
@@ -14379,7 +14722,7 @@ statsRouter.get("/retrieval-traces", async (c) => {
14379
14722
  projectHash: t.projectHash || null,
14380
14723
  queryRewriteKind,
14381
14724
  rewritten: queryRewriteKind !== "none",
14382
- strategy: t.strategy || null,
14725
+ strategy: normalizeRetrievalTraceStrategy(t.strategy ?? "unknown"),
14383
14726
  candidateEventIds: t.candidateEventIds,
14384
14727
  selectedEventIds: t.selectedEventIds,
14385
14728
  candidateDetails: t.candidateDetails || [],
@@ -14406,7 +14749,8 @@ statsRouter.get("/retrieval-traces", async (c) => {
14406
14749
  rewrittenSelectionRate: 0,
14407
14750
  rawSelectionRate: 0,
14408
14751
  avgSelectedCountForRewrittenQueries: 0,
14409
- avgSelectedCountForRawQueries: 0
14752
+ avgSelectedCountForRawQueries: 0,
14753
+ strategyBreakdown: []
14410
14754
  },
14411
14755
  traces: [],
14412
14756
  error: error.message
@@ -14457,7 +14801,8 @@ statsRouter.get("/kpi", async (c) => {
14457
14801
  await memoryService.initialize();
14458
14802
  const now = Date.now();
14459
14803
  const thresholds = loadKpiThresholds();
14460
- const allEvents = await memoryService.getRecentEvents(2e4);
14804
+ const lookbackMs = Math.max(2 * windowToMs(window), 30 * 24 * 60 * 60 * 1e3);
14805
+ const allEvents = await memoryService.getEventsAfter(new Date(now - lookbackMs).toISOString());
14461
14806
  const events = allEvents.filter((e) => inWindow(e, now, window));
14462
14807
  const helpfulness = await memoryService.getHelpfulnessStats();
14463
14808
  const usefulRecallRate = helpfulness.totalEvaluated > 0 ? round(safeRatio(helpfulness.helpful, helpfulness.totalEvaluated)) : 0;
@@ -14566,7 +14911,7 @@ statsRouter.get("/kpi", async (c) => {
14566
14911
  alerts
14567
14912
  });
14568
14913
  } catch (error) {
14569
- return c.json({ error: error.message }, 500);
14914
+ return jsonError(c, error);
14570
14915
  } finally {
14571
14916
  await memoryService.shutdown();
14572
14917
  }
@@ -14617,11 +14962,7 @@ citationsRouter.get("/:id", async (c) => {
14617
14962
  const memoryService = getServiceFromQuery(c);
14618
14963
  try {
14619
14964
  await memoryService.initialize();
14620
- const recentEvents = await memoryService.getRecentEvents(1e4);
14621
- const event = recentEvents.find((e) => {
14622
- const eventCitationId = generateCitationId(e.id);
14623
- return eventCitationId === citationId;
14624
- });
14965
+ const event = await memoryService.getEventByCitationId(citationId);
14625
14966
  if (!event) {
14626
14967
  return c.json({ error: "Citation not found" }, 404);
14627
14968
  }
@@ -14640,7 +14981,7 @@ citationsRouter.get("/:id", async (c) => {
14640
14981
  }
14641
14982
  });
14642
14983
  } catch (error) {
14643
- return c.json({ error: error.message }, 500);
14984
+ return jsonError(c, error);
14644
14985
  } finally {
14645
14986
  await memoryService.shutdown();
14646
14987
  }
@@ -14651,15 +14992,11 @@ citationsRouter.get("/:id/related", async (c) => {
14651
14992
  const memoryService = getServiceFromQuery(c);
14652
14993
  try {
14653
14994
  await memoryService.initialize();
14654
- const recentEvents = await memoryService.getRecentEvents(1e4);
14655
- const event = recentEvents.find((e) => {
14656
- const eventCitationId = generateCitationId(e.id);
14657
- return eventCitationId === citationId;
14658
- });
14995
+ const event = await memoryService.getEventByCitationId(citationId);
14659
14996
  if (!event) {
14660
14997
  return c.json({ error: "Citation not found" }, 404);
14661
14998
  }
14662
- const sessionEvents = recentEvents.filter((e) => e.sessionId === event.sessionId).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
14999
+ const sessionEvents = (await memoryService.getSessionHistory(event.sessionId)).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
14663
15000
  const eventIndex = sessionEvents.findIndex((e) => e.id === event.id);
14664
15001
  const prev = eventIndex > 0 ? sessionEvents[eventIndex - 1] : null;
14665
15002
  const next = eventIndex < sessionEvents.length - 1 ? sessionEvents[eventIndex + 1] : null;
@@ -14678,7 +15015,7 @@ citationsRouter.get("/:id/related", async (c) => {
14678
15015
  } : null
14679
15016
  });
14680
15017
  } catch (error) {
14681
- return c.json({ error: error.message }, 500);
15018
+ return jsonError(c, error);
14682
15019
  } finally {
14683
15020
  await memoryService.shutdown();
14684
15021
  }
@@ -14721,7 +15058,7 @@ turnsRouter.get("/", async (c) => {
14721
15058
  hasMore: offset + limit < totalTurns
14722
15059
  });
14723
15060
  } catch (error) {
14724
- return c.json({ error: error.message }, 500);
15061
+ return jsonError(c, error);
14725
15062
  } finally {
14726
15063
  await memoryService.shutdown();
14727
15064
  }
@@ -14772,7 +15109,7 @@ turnsRouter.get("/:turnId", async (c) => {
14772
15109
  totalEvents: events.length
14773
15110
  });
14774
15111
  } catch (error) {
14775
- return c.json({ error: error.message }, 500);
15112
+ return jsonError(c, error);
14776
15113
  } finally {
14777
15114
  await memoryService.shutdown();
14778
15115
  }
@@ -14799,8 +15136,8 @@ turnsRouter.post("/backfill", async (c) => {
14799
15136
 
14800
15137
  // src/apps/server/api/projects.ts
14801
15138
  import { Hono as Hono7 } from "hono";
14802
- import * as fs10 from "fs";
14803
- import * as path12 from "path";
15139
+ import * as fs11 from "fs";
15140
+ import * as path13 from "path";
14804
15141
  import * as os7 from "os";
14805
15142
  var projectsRouter = new Hono7();
14806
15143
  projectsRouter.get("/:hash/detail", async (c) => {
@@ -14829,7 +15166,7 @@ projectsRouter.get("/:hash/detail", async (c) => {
14829
15166
  return c.json({
14830
15167
  project: {
14831
15168
  hash,
14832
- projectName: projectPath ? path12.basename(projectPath) : `unknown (${hash})`,
15169
+ projectName: projectPath ? path13.basename(projectPath) : `unknown (${hash})`,
14833
15170
  registered: Boolean(projectPath)
14834
15171
  },
14835
15172
  storage: {
@@ -14857,13 +15194,13 @@ projectsRouter.get("/:hash/detail", async (c) => {
14857
15194
  });
14858
15195
  projectsRouter.get("/", async (c) => {
14859
15196
  try {
14860
- const projectsDir = path12.join(os7.homedir(), ".claude-code", "memory", "projects");
14861
- if (!fs10.existsSync(projectsDir)) {
15197
+ const projectsDir = path13.join(os7.homedir(), ".claude-code", "memory", "projects");
15198
+ if (!fs11.existsSync(projectsDir)) {
14862
15199
  return c.json({ projects: [] });
14863
15200
  }
14864
- const projectHashes = fs10.readdirSync(projectsDir).filter((name) => {
14865
- const fullPath = path12.join(projectsDir, name);
14866
- return fs10.statSync(fullPath).isDirectory();
15201
+ const projectHashes = fs11.readdirSync(projectsDir).filter((name) => {
15202
+ const fullPath = path13.join(projectsDir, name);
15203
+ return fs11.statSync(fullPath).isDirectory();
14867
15204
  });
14868
15205
  const registry = loadSessionRegistry();
14869
15206
  const hashToPath = /* @__PURE__ */ new Map();
@@ -14873,17 +15210,17 @@ projectsRouter.get("/", async (c) => {
14873
15210
  }
14874
15211
  }
14875
15212
  const projects = projectHashes.map((hash) => {
14876
- const dirPath = path12.join(projectsDir, hash);
14877
- const dbPath = path12.join(dirPath, "events.sqlite");
15213
+ const dirPath = path13.join(projectsDir, hash);
15214
+ const dbPath = path13.join(dirPath, "events.sqlite");
14878
15215
  let dbSize = 0;
14879
- if (fs10.existsSync(dbPath)) {
14880
- dbSize = fs10.statSync(dbPath).size;
15216
+ if (fs11.existsSync(dbPath)) {
15217
+ dbSize = fs11.statSync(dbPath).size;
14881
15218
  }
14882
15219
  const projectPath = hashToPath.get(hash) || `unknown (${hash})`;
14883
15220
  return {
14884
15221
  hash,
14885
15222
  projectPath,
14886
- projectName: path12.basename(projectPath),
15223
+ projectName: path13.basename(projectPath),
14887
15224
  dbSize,
14888
15225
  dbSizeHuman: formatBytes(dbSize)
14889
15226
  };
@@ -15012,7 +15349,7 @@ chatRouter.post("/", async (c) => {
15012
15349
  }
15013
15350
  });
15014
15351
  } catch (error) {
15015
- return c.json({ error: error.message }, 500);
15352
+ return jsonError(c, error);
15016
15353
  } finally {
15017
15354
  await memoryService.shutdown();
15018
15355
  }
@@ -15236,6 +15573,150 @@ function streamClaudeResponse(prompt, stream) {
15236
15573
  // src/apps/server/api/health.ts
15237
15574
  import { Hono as Hono9 } from "hono";
15238
15575
  import { spawnSync } from "child_process";
15576
+ import * as fs12 from "fs";
15577
+ import * as path14 from "path";
15578
+
15579
+ // src/core/productivity-health-report.ts
15580
+ var PRODUCTIVITY_HEALTH_SCHEMA_VERSION = "agent-productivity-health-v1";
15581
+ var PRODUCTIVITY_HEALTH_PROFILES = ["coder", "reviewer", "pm", "support", "researcher", "team"];
15582
+ var PRODUCTIVITY_HEALTH_MODES = ["observe", "preview", "enforce"];
15583
+ function parseProductivityHealthProfile(value) {
15584
+ const normalized = typeof value === "string" && value.trim().length > 0 ? value.trim() : "coder";
15585
+ if (PRODUCTIVITY_HEALTH_PROFILES.includes(normalized)) {
15586
+ return normalized;
15587
+ }
15588
+ throw new Error(`Invalid --profile: expected one of ${PRODUCTIVITY_HEALTH_PROFILES.join(", ")}`);
15589
+ }
15590
+ function parseProductivityHealthMode(value) {
15591
+ const normalized = typeof value === "string" && value.trim().length > 0 ? value.trim() : "preview";
15592
+ if (PRODUCTIVITY_HEALTH_MODES.includes(normalized)) {
15593
+ return normalized;
15594
+ }
15595
+ throw new Error(`Invalid --mode: expected one of ${PRODUCTIVITY_HEALTH_MODES.join(", ")}`);
15596
+ }
15597
+ function buildProductivityHealthReport(input) {
15598
+ const profile = input.profile ?? "coder";
15599
+ const mode = input.mode ?? "preview";
15600
+ const embedding = normalizeQueue(input.outbox.embedding);
15601
+ const vector = normalizeQueue(input.outbox.vector);
15602
+ const totals = sumQueues(embedding, vector);
15603
+ const storage = {
15604
+ totalEvents: numberOrZero(input.stats.totalEvents),
15605
+ vectorCount: numberOrZero(input.stats.vectorCount),
15606
+ levelStats: normalizeLevelStats(input.stats.levelStats)
15607
+ };
15608
+ const warningReasons = [];
15609
+ const projectGate = buildProjectScopeGate(input.project);
15610
+ const outboxGate = buildOutboxGate(totals);
15611
+ const densityGate = buildMemoryDensityGate(storage.totalEvents);
15612
+ const riskGates = [projectGate, outboxGate, densityGate];
15613
+ if (projectGate.status === "warn") warningReasons.push("project_scope_unknown");
15614
+ if (outboxGate.status === "warn") warningReasons.push("outbox_requires_attention");
15615
+ if (densityGate.status === "warn") warningReasons.push("memory_density_low");
15616
+ const status = riskGates.some((gate) => gate.status === "warn") ? "needs-attention" : "ok";
15617
+ return {
15618
+ schemaVersion: PRODUCTIVITY_HEALTH_SCHEMA_VERSION,
15619
+ generatedAt: (input.generatedAt ?? /* @__PURE__ */ new Date()).toISOString(),
15620
+ status,
15621
+ profile,
15622
+ mode,
15623
+ project: input.project,
15624
+ summary: { warningReasons },
15625
+ signals: {
15626
+ storage,
15627
+ outbox: { embedding, vector, totals }
15628
+ },
15629
+ riskGates,
15630
+ nextBestAction: selectNextBestAction({ projectGate, outboxGate, densityGate })
15631
+ };
15632
+ }
15633
+ function buildProjectScopeGate(project) {
15634
+ if (project.scope === "project") {
15635
+ return { id: "project-scope-known", severity: "blocker", status: "pass" };
15636
+ }
15637
+ return {
15638
+ id: "project-scope-known",
15639
+ severity: "blocker",
15640
+ status: "warn",
15641
+ message: "No project parameter supplied; report uses global memory scope."
15642
+ };
15643
+ }
15644
+ function buildOutboxGate(totals) {
15645
+ if (totals.failed > 0 || totals.stuckProcessing > 0) {
15646
+ return {
15647
+ id: "outbox-healthy",
15648
+ severity: "warning",
15649
+ status: "warn",
15650
+ message: "Outbox has failed or stuck processing items; run recovery before trusting retrieval freshness."
15651
+ };
15652
+ }
15653
+ return { id: "outbox-healthy", severity: "warning", status: "pass" };
15654
+ }
15655
+ function buildMemoryDensityGate(totalEvents) {
15656
+ if (totalEvents <= 0) {
15657
+ return {
15658
+ id: "memory-density",
15659
+ severity: "warning",
15660
+ status: "warn",
15661
+ message: "No memories are available for this scope yet."
15662
+ };
15663
+ }
15664
+ return { id: "memory-density", severity: "warning", status: "pass" };
15665
+ }
15666
+ function selectNextBestAction(input) {
15667
+ if (input.outboxGate.status === "warn") {
15668
+ return "Run claude-memory-layer process --dry-run-recovery, then process pending embeddings.";
15669
+ }
15670
+ if (input.densityGate.status === "warn") {
15671
+ return "Import or capture project context before relying on productivity memory guidance.";
15672
+ }
15673
+ if (input.projectGate.status === "warn") {
15674
+ return "Provide a project path or project hash before relying on project-scoped productivity guidance.";
15675
+ }
15676
+ return "No immediate maintenance action required.";
15677
+ }
15678
+ function normalizeLevelStats(levelStats) {
15679
+ if (!Array.isArray(levelStats)) return [];
15680
+ return levelStats.filter((entry) => typeof entry.level === "string" && entry.level.trim().length > 0).map((entry) => ({ level: entry.level.trim().slice(0, 20), count: numberOrZero(entry.count) }));
15681
+ }
15682
+ function normalizeQueue(queue) {
15683
+ return {
15684
+ pending: numberOrZero(queue.pending),
15685
+ processing: numberOrZero(queue.processing),
15686
+ failed: numberOrZero(queue.failed),
15687
+ retryableFailed: numberOrZero(queue.retryableFailed),
15688
+ quarantinedFailed: numberOrZero(queue.quarantinedFailed),
15689
+ total: numberOrZero(queue.total),
15690
+ stuckProcessing: numberOrZero(queue.stuckProcessing),
15691
+ oldestProcessingAgeMs: normalizeNullableAge(queue.oldestProcessingAgeMs)
15692
+ };
15693
+ }
15694
+ function sumQueues(a, b) {
15695
+ return {
15696
+ pending: a.pending + b.pending,
15697
+ processing: a.processing + b.processing,
15698
+ failed: a.failed + b.failed,
15699
+ retryableFailed: (a.retryableFailed ?? 0) + (b.retryableFailed ?? 0),
15700
+ quarantinedFailed: (a.quarantinedFailed ?? 0) + (b.quarantinedFailed ?? 0),
15701
+ total: a.total + b.total,
15702
+ stuckProcessing: a.stuckProcessing + b.stuckProcessing,
15703
+ oldestProcessingAgeMs: maxNullable(a.oldestProcessingAgeMs, b.oldestProcessingAgeMs)
15704
+ };
15705
+ }
15706
+ function normalizeNullableAge(value) {
15707
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
15708
+ }
15709
+ function maxNullable(a, b) {
15710
+ if (a === null) return b;
15711
+ if (b === null) return a;
15712
+ return Math.max(a, b);
15713
+ }
15714
+ function numberOrZero(value) {
15715
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
15716
+ return Math.floor(value);
15717
+ }
15718
+
15719
+ // src/apps/server/api/health.ts
15239
15720
  var healthRouter = new Hono9();
15240
15721
  function hasEnvSignal(name) {
15241
15722
  const value = process.env[name];
@@ -15276,6 +15757,33 @@ function aggregateOutbox(outbox) {
15276
15757
  const stuckProcessing = (outbox.embedding?.stuckProcessing || 0) + (outbox.vector?.stuckProcessing || 0);
15277
15758
  return { pending, processing, failed, retryableFailed, quarantinedFailed, stuckProcessing };
15278
15759
  }
15760
+ function resolveProductivityProjectIdentity(project) {
15761
+ if (!project || project.trim().length === 0) return { scope: "global", id: "global" };
15762
+ const normalized = project.trim();
15763
+ return { scope: "project", id: /^[a-f0-9]{8}$/.test(normalized) ? normalized : hashProjectPath(normalized) };
15764
+ }
15765
+ function explicitProjectStoreExists(project) {
15766
+ if (!project || project.trim().length === 0) return true;
15767
+ return fs12.existsSync(path14.join(resolveProjectStoragePath(project.trim()), "events.sqlite"));
15768
+ }
15769
+ function emptyProductivityStats() {
15770
+ return { totalEvents: 0, vectorCount: 0, levelStats: [] };
15771
+ }
15772
+ function emptyProductivityQueue() {
15773
+ return {
15774
+ pending: 0,
15775
+ processing: 0,
15776
+ failed: 0,
15777
+ retryableFailed: 0,
15778
+ quarantinedFailed: 0,
15779
+ total: 0,
15780
+ stuckProcessing: 0,
15781
+ oldestProcessingAgeMs: null
15782
+ };
15783
+ }
15784
+ function emptyProductivityOutbox() {
15785
+ return { embedding: emptyProductivityQueue(), vector: emptyProductivityQueue() };
15786
+ }
15279
15787
  healthRouter.get("/setup", async (c) => {
15280
15788
  const memoryService = getLightweightServiceFromQuery(c);
15281
15789
  try {
@@ -15332,6 +15840,40 @@ healthRouter.get("/setup", async (c) => {
15332
15840
  await memoryService.shutdown();
15333
15841
  }
15334
15842
  });
15843
+ healthRouter.get("/productivity", async (c) => {
15844
+ let memoryService;
15845
+ try {
15846
+ const profile = parseProductivityHealthProfile(c.req.query("profile"));
15847
+ const mode = parseProductivityHealthMode(c.req.query("mode"));
15848
+ const projectQuery = c.req.query("project") || c.req.query("projectId");
15849
+ const project = resolveProductivityProjectIdentity(projectQuery);
15850
+ if (!explicitProjectStoreExists(projectQuery)) {
15851
+ return c.json(buildProductivityHealthReport({
15852
+ stats: emptyProductivityStats(),
15853
+ outbox: emptyProductivityOutbox(),
15854
+ project,
15855
+ profile,
15856
+ mode
15857
+ }));
15858
+ }
15859
+ memoryService = getLightweightServiceFromQuery(c);
15860
+ await memoryService.initialize();
15861
+ const [stats, outbox] = await Promise.all([
15862
+ memoryService.getStats(),
15863
+ memoryService.getOutboxStats()
15864
+ ]);
15865
+ return c.json(buildProductivityHealthReport({ stats, outbox, project, profile, mode }));
15866
+ } catch (error) {
15867
+ const status = error instanceof Error && error.message.startsWith("Invalid --") ? 400 : 500;
15868
+ return c.json({
15869
+ status: "error",
15870
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
15871
+ error: status === 400 ? "Invalid productivity health option" : "Productivity health check failed"
15872
+ }, status);
15873
+ } finally {
15874
+ if (memoryService) await memoryService.shutdown();
15875
+ }
15876
+ });
15335
15877
  healthRouter.get("/", async (c) => {
15336
15878
  const memoryService = getLightweightServiceFromQuery(c);
15337
15879
  try {
@@ -15653,20 +16195,20 @@ playgroundRouter.post("/dry-run", async (c) => {
15653
16195
  var apiRouter = new Hono11().route("/sessions", sessionsRouter).route("/events", eventsRouter).route("/search", searchRouter).route("/stats", statsRouter).route("/citations", citationsRouter).route("/turns", turnsRouter).route("/projects", projectsRouter).route("/chat", chatRouter).route("/health", healthRouter).route("/playground", playgroundRouter);
15654
16196
 
15655
16197
  // src/apps/server/index.ts
15656
- var moduleDir = path13.dirname(fileUrlToPath(import.meta.url));
16198
+ var moduleDir = path15.dirname(fileUrlToPath(import.meta.url));
15657
16199
  var DASHBOARD_SESSION_COOKIE = "cml_dashboard_session";
15658
16200
  var DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
15659
16201
  function resolveUiPath() {
15660
16202
  const candidates = [
15661
16203
  // Built server: dist/server/index.js -> dist/ui
15662
- path13.resolve(moduleDir, "../ui"),
16204
+ path15.resolve(moduleDir, "../ui"),
15663
16205
  // Source/dev server: src/apps/server/index.ts -> src/apps/dashboard
15664
- path13.resolve(moduleDir, "../dashboard"),
16206
+ path15.resolve(moduleDir, "../dashboard"),
15665
16207
  // Fallback when running from a repository root.
15666
- path13.resolve(process.cwd(), "dist/ui"),
15667
- path13.resolve(process.cwd(), "src/apps/dashboard")
16208
+ path15.resolve(process.cwd(), "dist/ui"),
16209
+ path15.resolve(process.cwd(), "src/apps/dashboard")
15668
16210
  ];
15669
- return candidates.find((candidate) => fs11.existsSync(path13.join(candidate, "index.html"))) ?? candidates[0];
16211
+ return candidates.find((candidate) => fs13.existsSync(path15.join(candidate, "index.html"))) ?? candidates[0];
15670
16212
  }
15671
16213
  function normalizeDashboardHost(host = "localhost") {
15672
16214
  const normalized = host.trim().toLowerCase();
@@ -15807,14 +16349,51 @@ function createAuthOptions(options) {
15807
16349
  return {
15808
16350
  password: options.password ?? "",
15809
16351
  cookieMaxAgeSeconds: options.cookieMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
15810
- now: options.now ?? Date.now
16352
+ now: options.now ?? Date.now,
16353
+ allowedOrigins: options.allowedOrigins ?? []
15811
16354
  };
15812
16355
  }
16356
+ function resolveAllowedOrigins(explicit) {
16357
+ if (explicit && explicit.length > 0) return explicit;
16358
+ const raw = process.env.DASHBOARD_ALLOWED_ORIGINS;
16359
+ if (!raw) return [];
16360
+ return raw.split(",").map((value) => value.trim()).filter(Boolean);
16361
+ }
16362
+ function selfOrigins(c) {
16363
+ const origins = [];
16364
+ try {
16365
+ origins.push(new URL(c.req.url).origin);
16366
+ } catch {
16367
+ }
16368
+ const host = c.req.header("host");
16369
+ if (host) origins.push(`${isSecureRequest(c) ? "https" : "http"}://${host}`);
16370
+ return origins;
16371
+ }
16372
+ function isAllowedMutationOrigin(c, origin, allowedOrigins) {
16373
+ return allowedOrigins.includes(origin) || selfOrigins(c).includes(origin);
16374
+ }
15813
16375
  function createDashboardApp(options = {}) {
15814
16376
  const app2 = new Hono12();
15815
16377
  const authOptions = createAuthOptions(options);
15816
- app2.use("*", cors());
16378
+ const allowedOrigins = resolveAllowedOrigins(options.allowedOrigins);
16379
+ if (allowedOrigins.length > 0) {
16380
+ app2.use("*", cors({
16381
+ origin: (origin) => allowedOrigins.includes(origin) ? origin : null,
16382
+ credentials: true
16383
+ }));
16384
+ }
15817
16385
  app2.use("*", logger());
16386
+ app2.use("/api/*", async (c, next) => {
16387
+ const method = c.req.method;
16388
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
16389
+ return next();
16390
+ }
16391
+ const origin = c.req.header("origin");
16392
+ if (origin && !isAllowedMutationOrigin(c, origin, allowedOrigins)) {
16393
+ return c.json({ error: "Cross-origin request blocked" }, 403);
16394
+ }
16395
+ return next();
16396
+ });
15818
16397
  app2.get("/health", (c) => c.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
15819
16398
  app2.get("/api/auth/status", (c) => c.json({
15820
16399
  enabled: Boolean(authOptions.password),
@@ -15857,13 +16436,13 @@ function createDashboardApp(options = {}) {
15857
16436
  }
15858
16437
  app2.route("/api", apiRouter);
15859
16438
  const uiPath = resolveUiPath();
15860
- if (fs11.existsSync(uiPath)) {
16439
+ if (fs13.existsSync(uiPath)) {
15861
16440
  app2.use("/*", serveStatic({ root: uiPath }));
15862
16441
  }
15863
16442
  app2.get("*", (c) => {
15864
- const indexPath = path13.join(uiPath, "index.html");
15865
- if (fs11.existsSync(indexPath)) {
15866
- return c.html(fs11.readFileSync(indexPath, "utf-8"));
16443
+ const indexPath = path15.join(uiPath, "index.html");
16444
+ if (fs13.existsSync(indexPath)) {
16445
+ return c.html(fs13.readFileSync(indexPath, "utf-8"));
15867
16446
  }
15868
16447
  return c.text('UI not built. Run "npm run build:ui" first.', 404);
15869
16448
  });