claude-memory-layer 1.0.18 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/config/kpi-thresholds.json +7 -0
  2. package/dist/cli/index.js +532 -79
  3. package/dist/cli/index.js.map +3 -3
  4. package/dist/core/index.js +49 -4
  5. package/dist/core/index.js.map +2 -2
  6. package/dist/hooks/post-tool-use.js +140 -3
  7. package/dist/hooks/post-tool-use.js.map +2 -2
  8. package/dist/hooks/session-end.js +140 -3
  9. package/dist/hooks/session-end.js.map +2 -2
  10. package/dist/hooks/session-start.js +140 -3
  11. package/dist/hooks/session-start.js.map +2 -2
  12. package/dist/hooks/stop.js +140 -3
  13. package/dist/hooks/stop.js.map +2 -2
  14. package/dist/hooks/user-prompt-submit.js +379 -34
  15. package/dist/hooks/user-prompt-submit.js.map +3 -3
  16. package/dist/server/api/index.js +467 -34
  17. package/dist/server/api/index.js.map +3 -3
  18. package/dist/server/index.js +474 -41
  19. package/dist/server/index.js.map +3 -3
  20. package/dist/services/memory-service.js +140 -3
  21. package/dist/services/memory-service.js.map +2 -2
  22. package/dist/ui/app.js +362 -4
  23. package/dist/ui/index.html +90 -0
  24. package/dist/ui/style.css +41 -0
  25. package/memory/_index.md +3 -0
  26. package/memory/agent_response/uncategorized/2026-03-03.md +14 -0
  27. package/memory/session_summary/uncategorized/2026-03-03.md +5 -0
  28. package/memory/tool_observation/uncategorized/2026-03-03.md +21 -0
  29. package/package.json +3 -2
  30. package/scripts/delete-unknown-projects.js +154 -0
  31. package/src/cli/index.ts +23 -1
  32. package/src/core/embedder.ts +3 -2
  33. package/src/core/sqlite-event-store.ts +32 -0
  34. package/src/core/types.ts +2 -2
  35. package/src/core/vector-store.ts +20 -0
  36. package/src/hooks/user-prompt-submit.ts +225 -29
  37. package/src/server/api/events.ts +7 -0
  38. package/src/server/api/stats.ts +346 -0
  39. package/src/services/memory-service.ts +119 -2
  40. package/src/ui/app.js +362 -4
  41. package/src/ui/index.html +90 -0
  42. package/src/ui/style.css +41 -0
@@ -83,57 +83,57 @@ function toDate(value) {
83
83
  return new Date(value);
84
84
  return new Date(String(value));
85
85
  }
86
- function createDatabase(path6, options) {
86
+ function createDatabase(path7, options) {
87
87
  if (options?.readOnly) {
88
- return new duckdb.Database(path6, { access_mode: "READ_ONLY" });
88
+ return new duckdb.Database(path7, { access_mode: "READ_ONLY" });
89
89
  }
90
- return new duckdb.Database(path6);
90
+ return new duckdb.Database(path7);
91
91
  }
92
92
  function dbRun(db, sql, params = []) {
93
- return new Promise((resolve2, reject) => {
93
+ return new Promise((resolve3, reject) => {
94
94
  if (params.length === 0) {
95
95
  db.run(sql, (err) => {
96
96
  if (err)
97
97
  reject(err);
98
98
  else
99
- resolve2();
99
+ resolve3();
100
100
  });
101
101
  } else {
102
102
  db.run(sql, ...params, (err) => {
103
103
  if (err)
104
104
  reject(err);
105
105
  else
106
- resolve2();
106
+ resolve3();
107
107
  });
108
108
  }
109
109
  });
110
110
  }
111
111
  function dbAll(db, sql, params = []) {
112
- return new Promise((resolve2, reject) => {
112
+ return new Promise((resolve3, reject) => {
113
113
  if (params.length === 0) {
114
114
  db.all(sql, (err, rows) => {
115
115
  if (err)
116
116
  reject(err);
117
117
  else
118
- resolve2(convertBigInts(rows || []));
118
+ resolve3(convertBigInts(rows || []));
119
119
  });
120
120
  } else {
121
121
  db.all(sql, ...params, (err, rows) => {
122
122
  if (err)
123
123
  reject(err);
124
124
  else
125
- resolve2(convertBigInts(rows || []));
125
+ resolve3(convertBigInts(rows || []));
126
126
  });
127
127
  }
128
128
  });
129
129
  }
130
130
  function dbClose(db) {
131
- return new Promise((resolve2, reject) => {
131
+ return new Promise((resolve3, reject) => {
132
132
  db.close((err) => {
133
133
  if (err)
134
134
  reject(err);
135
135
  else
136
- resolve2();
136
+ resolve3();
137
137
  });
138
138
  });
139
139
  }
@@ -771,12 +771,12 @@ import { randomUUID as randomUUID2 } from "crypto";
771
771
  import Database from "better-sqlite3";
772
772
  import * as fs from "fs";
773
773
  import * as nodePath from "path";
774
- function createSQLiteDatabase(path6, options) {
775
- const dir = nodePath.dirname(path6);
774
+ function createSQLiteDatabase(path7, options) {
775
+ const dir = nodePath.dirname(path7);
776
776
  if (!fs.existsSync(dir)) {
777
777
  fs.mkdirSync(dir, { recursive: true });
778
778
  }
779
- const db = new Database(path6, {
779
+ const db = new Database(path7, {
780
780
  readonly: options?.readonly ?? false
781
781
  });
782
782
  if (!options?.readonly && (options?.walMode ?? true)) {
@@ -1615,6 +1615,33 @@ var SQLiteEventStore = class {
1615
1615
  ids
1616
1616
  );
1617
1617
  }
1618
+ /**
1619
+ * Clear embedding outbox (used for embedding model migration)
1620
+ */
1621
+ async clearEmbeddingOutbox() {
1622
+ await this.initialize();
1623
+ sqliteRun(this.db, `DELETE FROM embedding_outbox`);
1624
+ }
1625
+ /**
1626
+ * Count total events
1627
+ */
1628
+ async countEvents() {
1629
+ await this.initialize();
1630
+ const row = sqliteGet(this.db, `SELECT COUNT(*) as count FROM events`);
1631
+ return row?.count || 0;
1632
+ }
1633
+ /**
1634
+ * Get events page in timestamp ascending order (stable migration/reindex scans)
1635
+ */
1636
+ async getEventsPage(limit = 1e3, offset = 0) {
1637
+ await this.initialize();
1638
+ const rows = sqliteAll(
1639
+ this.db,
1640
+ `SELECT * FROM events ORDER BY timestamp ASC LIMIT ? OFFSET ?`,
1641
+ [limit, offset]
1642
+ );
1643
+ return rows.map(this.rowToEvent);
1644
+ }
1618
1645
  /**
1619
1646
  * Mark outbox items as failed
1620
1647
  */
@@ -2422,7 +2449,7 @@ var SyncWorker = class {
2422
2449
  * Sleep utility
2423
2450
  */
2424
2451
  sleep(ms) {
2425
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2452
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
2426
2453
  }
2427
2454
  /**
2428
2455
  * Get sync statistics
@@ -2580,6 +2607,23 @@ var VectorStore = class {
2580
2607
  const result = await this.table.countRows();
2581
2608
  return result;
2582
2609
  }
2610
+ /**
2611
+ * Clear all vectors (used for embedding model migration)
2612
+ */
2613
+ async clearAll() {
2614
+ await this.initialize();
2615
+ if (!this.db)
2616
+ return;
2617
+ try {
2618
+ if (typeof this.db.dropTable === "function") {
2619
+ await this.db.dropTable(this.tableName);
2620
+ } else if (typeof this.db.drop_table === "function") {
2621
+ await this.db.drop_table(this.tableName);
2622
+ }
2623
+ } catch {
2624
+ }
2625
+ this.table = null;
2626
+ }
2583
2627
  /**
2584
2628
  * Check if vector exists for event
2585
2629
  */
@@ -2597,7 +2641,7 @@ var Embedder = class {
2597
2641
  pipeline = null;
2598
2642
  modelName;
2599
2643
  initialized = false;
2600
- constructor(modelName = "Xenova/all-MiniLM-L6-v2") {
2644
+ constructor(modelName = "jinaai/jina-embeddings-v5-text-nano") {
2601
2645
  this.modelName = modelName;
2602
2646
  }
2603
2647
  /**
@@ -2677,8 +2721,9 @@ var Embedder = class {
2677
2721
  };
2678
2722
  var defaultEmbedder = null;
2679
2723
  function getDefaultEmbedder() {
2724
+ const envModel = process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
2680
2725
  if (!defaultEmbedder) {
2681
- defaultEmbedder = new Embedder();
2726
+ defaultEmbedder = new Embedder(envModel || void 0);
2682
2727
  }
2683
2728
  return defaultEmbedder;
2684
2729
  }
@@ -3452,8 +3497,8 @@ _Context:_ ${sessionContext}`;
3452
3497
  matchesMetadataScope(metadata, expected) {
3453
3498
  if (!metadata)
3454
3499
  return false;
3455
- return Object.entries(expected).every(([path6, value]) => {
3456
- const actual = path6.split(".").reduce((acc, key) => {
3500
+ return Object.entries(expected).every(([path7, value]) => {
3501
+ const actual = path7.split(".").reduce((acc, key) => {
3457
3502
  if (typeof acc !== "object" || acc === null)
3458
3503
  return void 0;
3459
3504
  return acc[key];
@@ -5909,8 +5954,10 @@ var MemoryService = class {
5909
5954
  readOnly;
5910
5955
  lightweightMode;
5911
5956
  mdMirror;
5957
+ storagePath;
5912
5958
  constructor(config) {
5913
5959
  const storagePath = this.expandPath(config.storagePath);
5960
+ this.storagePath = storagePath;
5914
5961
  this.readOnly = config.readOnly ?? false;
5915
5962
  this.lightweightMode = config.lightweightMode ?? false;
5916
5963
  this.mdMirror = new MarkdownMirror2(process.cwd());
@@ -5946,7 +5993,8 @@ var MemoryService = class {
5946
5993
  );
5947
5994
  }
5948
5995
  this.vectorStore = new VectorStore(path3.join(storagePath, "vectors"));
5949
- this.embedder = config.embeddingModel ? new Embedder(config.embeddingModel) : getDefaultEmbedder();
5996
+ const embeddingModel = config.embeddingModel || process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
5997
+ this.embedder = embeddingModel ? new Embedder(embeddingModel) : getDefaultEmbedder();
5950
5998
  this.matcher = getDefaultMatcher();
5951
5999
  this.retriever = createRetriever(
5952
6000
  this.sqliteStore,
@@ -6890,6 +6938,95 @@ var MemoryService = class {
6890
6938
  recordMemoryAccess(eventId, sessionId, confidence = 1) {
6891
6939
  this.graduation.recordAccess(eventId, sessionId, confidence);
6892
6940
  }
6941
+ getEmbeddingModelName() {
6942
+ return this.embedder.getModelName();
6943
+ }
6944
+ /**
6945
+ * Ensure embedding model metadata is in sync and optionally migrate vectors.
6946
+ * Migration strategy: clear vector index + clear embedding outbox + re-enqueue all events.
6947
+ */
6948
+ async ensureEmbeddingModelForImport(options) {
6949
+ await this.initialize();
6950
+ const currentModel = this.getEmbeddingModelName();
6951
+ const metaPath = path3.join(this.storagePath, "embedding-meta.json");
6952
+ let previousModel = null;
6953
+ try {
6954
+ if (fs4.existsSync(metaPath)) {
6955
+ const parsed = JSON.parse(fs4.readFileSync(metaPath, "utf-8"));
6956
+ previousModel = parsed?.model || null;
6957
+ }
6958
+ } catch {
6959
+ previousModel = null;
6960
+ }
6961
+ const stats = await this.getStats();
6962
+ const hasExistingVectors = (stats.vectorCount || 0) > 0;
6963
+ if (!previousModel && !hasExistingVectors) {
6964
+ fs4.writeFileSync(metaPath, JSON.stringify({ model: currentModel, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
6965
+ return { changed: false, previousModel: null, currentModel, enqueued: 0, reason: "initialized-meta" };
6966
+ }
6967
+ const modelChanged = previousModel !== currentModel;
6968
+ const legacyUnknownButVectorsExist = !previousModel && hasExistingVectors;
6969
+ if (!modelChanged && !legacyUnknownButVectorsExist) {
6970
+ return { changed: false, previousModel, currentModel, enqueued: 0 };
6971
+ }
6972
+ if (options?.autoMigrate === false) {
6973
+ return {
6974
+ changed: true,
6975
+ previousModel,
6976
+ currentModel,
6977
+ enqueued: 0,
6978
+ reason: legacyUnknownButVectorsExist ? "legacy-vectors-without-meta" : "model-mismatch"
6979
+ };
6980
+ }
6981
+ const wasRunning = this.vectorWorker?.isRunning() || false;
6982
+ if (wasRunning)
6983
+ this.vectorWorker?.stop();
6984
+ await this.vectorStore.clearAll();
6985
+ await this.sqliteStore.clearEmbeddingOutbox();
6986
+ const pageSize = 1e3;
6987
+ let offset = 0;
6988
+ let enqueued = 0;
6989
+ while (true) {
6990
+ const page = await this.sqliteStore.getEventsPage(pageSize, offset);
6991
+ if (page.length === 0)
6992
+ break;
6993
+ for (const event of page) {
6994
+ await this.sqliteStore.enqueueForEmbedding(event.id, event.content);
6995
+ enqueued += 1;
6996
+ }
6997
+ offset += page.length;
6998
+ if (page.length < pageSize)
6999
+ break;
7000
+ }
7001
+ fs4.writeFileSync(
7002
+ metaPath,
7003
+ JSON.stringify(
7004
+ {
7005
+ model: currentModel,
7006
+ previousModel,
7007
+ migratedAt: (/* @__PURE__ */ new Date()).toISOString(),
7008
+ enqueued
7009
+ },
7010
+ null,
7011
+ 2
7012
+ )
7013
+ );
7014
+ if (wasRunning)
7015
+ this.vectorWorker?.start();
7016
+ return {
7017
+ changed: true,
7018
+ previousModel,
7019
+ currentModel,
7020
+ enqueued,
7021
+ reason: legacyUnknownButVectorsExist ? "legacy-vectors-without-meta" : "model-mismatch"
7022
+ };
7023
+ }
7024
+ /**
7025
+ * Backward-compatible alias used by some hooks
7026
+ */
7027
+ async close() {
7028
+ await this.shutdown();
7029
+ }
6893
7030
  /**
6894
7031
  * Shutdown service
6895
7032
  */
@@ -7068,6 +7205,7 @@ eventsRouter.get("/", async (c) => {
7068
7205
  const eventType = c.req.query("type");
7069
7206
  const level = c.req.query("level");
7070
7207
  const sort = c.req.query("sort") || "recent";
7208
+ const q = (c.req.query("q") || "").trim().toLowerCase();
7071
7209
  const limit = parseInt(c.req.query("limit") || "100", 10);
7072
7210
  const offset = parseInt(c.req.query("offset") || "0", 10);
7073
7211
  const memoryService = getServiceFromQuery(c);
@@ -7085,6 +7223,9 @@ eventsRouter.get("/", async (c) => {
7085
7223
  if (eventType) {
7086
7224
  events = events.filter((e) => e.eventType === eventType);
7087
7225
  }
7226
+ if (q) {
7227
+ events = events.filter((e) => (e.content || "").toLowerCase().includes(q));
7228
+ }
7088
7229
  if (sort === "accessed") {
7089
7230
  events.sort((a, b) => {
7090
7231
  const aTime = a.last_accessed_at || "";
@@ -7106,6 +7247,7 @@ eventsRouter.get("/", async (c) => {
7106
7247
  sessionId: e.sessionId,
7107
7248
  preview: e.content.slice(0, 200) + (e.content.length > 200 ? "..." : ""),
7108
7249
  contentLength: e.content.length,
7250
+ metadata: e.metadata,
7109
7251
  accessCount: e.access_count || 0,
7110
7252
  lastAccessedAt: e.last_accessed_at || null
7111
7253
  })),
@@ -7232,7 +7374,175 @@ searchRouter.get("/", async (c) => {
7232
7374
 
7233
7375
  // src/server/api/stats.ts
7234
7376
  import { Hono as Hono4 } from "hono";
7377
+ import * as fs5 from "fs";
7378
+ import * as path5 from "path";
7235
7379
  var statsRouter = new Hono4();
7380
+ var DEFAULT_KPI_THRESHOLDS = {
7381
+ usefulRecallRateMin: 0.45,
7382
+ reworkRateMax: 0.25,
7383
+ postChangeFailureRateMax: 0.2,
7384
+ avgCompletionTurnsMax: 12,
7385
+ memoryHitRateMin: 0.35
7386
+ };
7387
+ function loadKpiThresholds() {
7388
+ try {
7389
+ const filePath = path5.resolve(process.cwd(), "config", "kpi-thresholds.json");
7390
+ if (!fs5.existsSync(filePath))
7391
+ return DEFAULT_KPI_THRESHOLDS;
7392
+ const parsed = JSON.parse(fs5.readFileSync(filePath, "utf-8"));
7393
+ return {
7394
+ usefulRecallRateMin: Number(parsed.usefulRecallRateMin ?? DEFAULT_KPI_THRESHOLDS.usefulRecallRateMin),
7395
+ reworkRateMax: Number(parsed.reworkRateMax ?? DEFAULT_KPI_THRESHOLDS.reworkRateMax),
7396
+ postChangeFailureRateMax: Number(parsed.postChangeFailureRateMax ?? DEFAULT_KPI_THRESHOLDS.postChangeFailureRateMax),
7397
+ avgCompletionTurnsMax: Number(parsed.avgCompletionTurnsMax ?? DEFAULT_KPI_THRESHOLDS.avgCompletionTurnsMax),
7398
+ memoryHitRateMin: Number(parsed.memoryHitRateMin ?? DEFAULT_KPI_THRESHOLDS.memoryHitRateMin)
7399
+ };
7400
+ } catch {
7401
+ return DEFAULT_KPI_THRESHOLDS;
7402
+ }
7403
+ }
7404
+ function windowToMs(window) {
7405
+ if (window === "24h")
7406
+ return 24 * 60 * 60 * 1e3;
7407
+ if (window === "7d")
7408
+ return 7 * 24 * 60 * 60 * 1e3;
7409
+ return 30 * 24 * 60 * 60 * 1e3;
7410
+ }
7411
+ function inWindow(e, now, window) {
7412
+ return now - e.timestamp.getTime() <= windowToMs(window);
7413
+ }
7414
+ function isEditToolName(name) {
7415
+ return ["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name);
7416
+ }
7417
+ function parseToolPayload(e) {
7418
+ if (e.eventType !== "tool_observation")
7419
+ return null;
7420
+ try {
7421
+ const payload = JSON.parse(e.content);
7422
+ return {
7423
+ toolName: payload?.toolName,
7424
+ success: payload?.success,
7425
+ filePath: payload?.metadata?.filePath,
7426
+ command: payload?.metadata?.command
7427
+ };
7428
+ } catch {
7429
+ return {
7430
+ toolName: e.metadata?.toolName,
7431
+ success: e.metadata?.success,
7432
+ filePath: e.metadata?.filePath,
7433
+ command: e.metadata?.command
7434
+ };
7435
+ }
7436
+ }
7437
+ function isTestLikeCommand(command) {
7438
+ if (!command)
7439
+ return false;
7440
+ return /(test|jest|vitest|pytest|go test|cargo test|lint|eslint|build|tsc)/i.test(command);
7441
+ }
7442
+ function safeRatio(num, den) {
7443
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0)
7444
+ return 0;
7445
+ return num / den;
7446
+ }
7447
+ function round(value, digits = 4) {
7448
+ const factor = 10 ** digits;
7449
+ return Math.round(value * factor) / factor;
7450
+ }
7451
+ function computeSessionTurnCount(sessionEvents) {
7452
+ const turnIds = /* @__PURE__ */ new Set();
7453
+ for (const e of sessionEvents) {
7454
+ const turnId = e.metadata?.turnId;
7455
+ if (typeof turnId === "string" && turnId.length > 0)
7456
+ turnIds.add(turnId);
7457
+ }
7458
+ if (turnIds.size > 0)
7459
+ return turnIds.size;
7460
+ return sessionEvents.filter((e) => e.eventType === "user_prompt").length;
7461
+ }
7462
+ function computeKpiMetrics(events, usefulRecallRate) {
7463
+ const prompts = events.filter((e) => e.eventType === "user_prompt");
7464
+ const promptCount = prompts.length;
7465
+ const memoryHitPrompts = prompts.filter((p) => p.metadata?.adherence?.checked).length;
7466
+ const memoryHitRate = round(safeRatio(memoryHitPrompts, promptCount));
7467
+ const sessions = /* @__PURE__ */ new Map();
7468
+ for (const e of events) {
7469
+ const arr = sessions.get(e.sessionId) || [];
7470
+ arr.push(e);
7471
+ sessions.set(e.sessionId, arr);
7472
+ }
7473
+ let sessionTurnTotal = 0;
7474
+ let sessionTurnSamples = 0;
7475
+ let firstValidEditMinutesTotal = 0;
7476
+ let firstValidEditSamples = 0;
7477
+ for (const sessionEvents of sessions.values()) {
7478
+ sessionEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
7479
+ const turns = computeSessionTurnCount(sessionEvents);
7480
+ if (turns > 0) {
7481
+ sessionTurnTotal += turns;
7482
+ sessionTurnSamples++;
7483
+ }
7484
+ const firstPrompt = sessionEvents.find((e) => e.eventType === "user_prompt");
7485
+ const firstEdit = sessionEvents.find((e) => {
7486
+ const payload = parseToolPayload(e);
7487
+ return payload?.toolName && isEditToolName(payload.toolName) && payload.success === true;
7488
+ });
7489
+ if (firstPrompt && firstEdit) {
7490
+ const minutes = (firstEdit.timestamp.getTime() - firstPrompt.timestamp.getTime()) / 6e4;
7491
+ if (minutes >= 0) {
7492
+ firstValidEditMinutesTotal += minutes;
7493
+ firstValidEditSamples++;
7494
+ }
7495
+ }
7496
+ }
7497
+ const avgCompletionTurns = round(safeRatio(sessionTurnTotal, sessionTurnSamples), 2);
7498
+ const timeToFirstValidEditMinutes = round(safeRatio(firstValidEditMinutesTotal, firstValidEditSamples), 2);
7499
+ const editActions = [];
7500
+ let testRunsAfterEdit = 0;
7501
+ let failedTestRunsAfterEdit = 0;
7502
+ for (const [sessionId, sessionEvents] of sessions.entries()) {
7503
+ const sorted = [...sessionEvents].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
7504
+ let seenEdit = false;
7505
+ for (const e of sorted) {
7506
+ const payload = parseToolPayload(e);
7507
+ if (!payload?.toolName)
7508
+ continue;
7509
+ if (isEditToolName(payload.toolName) && payload.success === true) {
7510
+ editActions.push({ sessionId, timestamp: e.timestamp.getTime(), filePath: payload.filePath });
7511
+ seenEdit = true;
7512
+ continue;
7513
+ }
7514
+ if (seenEdit && isTestLikeCommand(payload.command)) {
7515
+ testRunsAfterEdit++;
7516
+ if (payload.success === false)
7517
+ failedTestRunsAfterEdit++;
7518
+ }
7519
+ }
7520
+ }
7521
+ const THIRTY_MIN_MS = 30 * 60 * 1e3;
7522
+ let reworkCount = 0;
7523
+ const bySessionFile = /* @__PURE__ */ new Map();
7524
+ const sortedEdits = [...editActions].sort((a, b) => a.timestamp - b.timestamp);
7525
+ for (const edit of sortedEdits) {
7526
+ if (!edit.filePath)
7527
+ continue;
7528
+ const key = `${edit.sessionId}::${edit.filePath}`;
7529
+ const prev = bySessionFile.get(key);
7530
+ if (typeof prev === "number" && edit.timestamp - prev <= THIRTY_MIN_MS) {
7531
+ reworkCount++;
7532
+ }
7533
+ bySessionFile.set(key, edit.timestamp);
7534
+ }
7535
+ const reworkRate = round(safeRatio(reworkCount, editActions.length));
7536
+ const postChangeFailureRate = round(safeRatio(failedTestRunsAfterEdit, testRunsAfterEdit));
7537
+ return {
7538
+ memoryHitRate,
7539
+ usefulRecallRate,
7540
+ avgCompletionTurns,
7541
+ timeToFirstValidEditMinutes,
7542
+ reworkRate,
7543
+ postChangeFailureRate
7544
+ };
7545
+ }
7236
7546
  statsRouter.get("/shared", async (c) => {
7237
7547
  const memoryService = getServiceFromQuery(c);
7238
7548
  try {
@@ -7512,6 +7822,129 @@ statsRouter.get("/retrieval-traces", async (c) => {
7512
7822
  await memoryService.shutdown();
7513
7823
  }
7514
7824
  });
7825
+ statsRouter.get("/kpi", async (c) => {
7826
+ const rawWindow = c.req.query("window") || "7d";
7827
+ const window = rawWindow === "24h" || rawWindow === "30d" ? rawWindow : "7d";
7828
+ const memoryService = getServiceFromQuery(c);
7829
+ try {
7830
+ await memoryService.initialize();
7831
+ const now = Date.now();
7832
+ const thresholds = loadKpiThresholds();
7833
+ const allEvents = await memoryService.getRecentEvents(2e4);
7834
+ const events = allEvents.filter((e) => inWindow(e, now, window));
7835
+ const helpfulness = await memoryService.getHelpfulnessStats();
7836
+ const usefulRecallRate = helpfulness.totalEvaluated > 0 ? round(safeRatio(helpfulness.helpful, helpfulness.totalEvaluated)) : 0;
7837
+ const metrics = computeKpiMetrics(events, usefulRecallRate);
7838
+ const windowMs = windowToMs(window);
7839
+ const prevEvents = allEvents.filter((e) => {
7840
+ const age = now - e.timestamp.getTime();
7841
+ return age > windowMs && age <= windowMs * 2;
7842
+ });
7843
+ const previousMetrics = computeKpiMetrics(prevEvents, usefulRecallRate);
7844
+ const deltas = {
7845
+ memoryHitRate: round(metrics.memoryHitRate - previousMetrics.memoryHitRate),
7846
+ usefulRecallRate: round(metrics.usefulRecallRate - previousMetrics.usefulRecallRate),
7847
+ avgCompletionTurns: round(metrics.avgCompletionTurns - previousMetrics.avgCompletionTurns, 2),
7848
+ timeToFirstValidEditMinutes: round(metrics.timeToFirstValidEditMinutes - previousMetrics.timeToFirstValidEditMinutes, 2),
7849
+ reworkRate: round(metrics.reworkRate - previousMetrics.reworkRate),
7850
+ postChangeFailureRate: round(metrics.postChangeFailureRate - previousMetrics.postChangeFailureRate)
7851
+ };
7852
+ const THIRTY_MIN_MS = 30 * 60 * 1e3;
7853
+ const trendWindowMs = 30 * 24 * 60 * 60 * 1e3;
7854
+ const trendEvents = allEvents.filter((e) => now - e.timestamp.getTime() <= trendWindowMs);
7855
+ const buckets = /* @__PURE__ */ new Map();
7856
+ for (const e of trendEvents) {
7857
+ const day = e.timestamp.toISOString().split("T")[0];
7858
+ const arr = buckets.get(day) || [];
7859
+ arr.push(e);
7860
+ buckets.set(day, arr);
7861
+ }
7862
+ const trendDaily = Array.from(buckets.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([date, dayEvents]) => {
7863
+ const dayPrompts = dayEvents.filter((e) => e.eventType === "user_prompt");
7864
+ const dayPromptCount = dayPrompts.length;
7865
+ const dayMemoryHit = dayPrompts.filter((p) => p.metadata?.adherence?.checked).length;
7866
+ const dayEdits = dayEvents.filter((e) => {
7867
+ const p = parseToolPayload(e);
7868
+ return Boolean(p?.toolName && isEditToolName(p.toolName) && p.success === true);
7869
+ });
7870
+ const dayEditActions = dayEdits.map((e) => {
7871
+ const p = parseToolPayload(e);
7872
+ return { sessionId: e.sessionId, timestamp: e.timestamp.getTime(), filePath: p?.filePath };
7873
+ }).filter((x) => Boolean(x.filePath));
7874
+ let dayReworkCount = 0;
7875
+ const dayBySessionFile = /* @__PURE__ */ new Map();
7876
+ for (const edit of dayEditActions) {
7877
+ const key = `${edit.sessionId}::${edit.filePath}`;
7878
+ const prev = dayBySessionFile.get(key);
7879
+ if (typeof prev === "number" && edit.timestamp - prev <= THIRTY_MIN_MS)
7880
+ dayReworkCount++;
7881
+ dayBySessionFile.set(key, edit.timestamp);
7882
+ }
7883
+ const dayTests = dayEvents.filter((e) => {
7884
+ const p = parseToolPayload(e);
7885
+ return Boolean(p?.toolName && isTestLikeCommand(p.command));
7886
+ });
7887
+ const dayFailedTests = dayEvents.filter((e) => {
7888
+ const p = parseToolPayload(e);
7889
+ return Boolean(p?.toolName && isTestLikeCommand(p.command) && p.success === false);
7890
+ });
7891
+ const turnsBySession = /* @__PURE__ */ new Map();
7892
+ for (const e of dayEvents) {
7893
+ const arr = turnsBySession.get(e.sessionId) || [];
7894
+ arr.push(e);
7895
+ turnsBySession.set(e.sessionId, arr);
7896
+ }
7897
+ let dayTurnsTotal = 0;
7898
+ let dayTurnsSamples = 0;
7899
+ for (const sessionEvents of turnsBySession.values()) {
7900
+ const turns = computeSessionTurnCount(sessionEvents);
7901
+ if (turns > 0) {
7902
+ dayTurnsTotal += turns;
7903
+ dayTurnsSamples++;
7904
+ }
7905
+ }
7906
+ return {
7907
+ date,
7908
+ memoryHitRate: round(safeRatio(dayMemoryHit, dayPromptCount)),
7909
+ usefulRecallRate,
7910
+ reworkRate: round(safeRatio(dayReworkCount, dayEditActions.length)),
7911
+ postChangeFailureRate: round(safeRatio(dayFailedTests.length, dayTests.length)),
7912
+ avgCompletionTurns: round(safeRatio(dayTurnsTotal, dayTurnsSamples), 2)
7913
+ };
7914
+ });
7915
+ const alerts = [];
7916
+ if (metrics.usefulRecallRate < thresholds.usefulRecallRateMin) {
7917
+ alerts.push({ metric: "usefulRecallRate", level: "warn", message: "Useful recall rate is below threshold", value: metrics.usefulRecallRate, threshold: thresholds.usefulRecallRateMin });
7918
+ }
7919
+ if (metrics.reworkRate > thresholds.reworkRateMax) {
7920
+ alerts.push({ metric: "reworkRate", level: "warn", message: "Rework rate is above threshold", value: metrics.reworkRate, threshold: thresholds.reworkRateMax });
7921
+ }
7922
+ if (metrics.postChangeFailureRate > thresholds.postChangeFailureRateMax) {
7923
+ alerts.push({ metric: "postChangeFailureRate", level: "warn", message: "Post-change failure rate is above threshold", value: metrics.postChangeFailureRate, threshold: thresholds.postChangeFailureRateMax });
7924
+ }
7925
+ if (metrics.avgCompletionTurns > thresholds.avgCompletionTurnsMax) {
7926
+ alerts.push({ metric: "avgCompletionTurns", level: "warn", message: "Average completion turns is above threshold", value: metrics.avgCompletionTurns, threshold: thresholds.avgCompletionTurnsMax });
7927
+ }
7928
+ if (metrics.memoryHitRate < thresholds.memoryHitRateMin) {
7929
+ alerts.push({ metric: "memoryHitRate", level: "warn", message: "Memory hit rate is below threshold", value: metrics.memoryHitRate, threshold: thresholds.memoryHitRateMin });
7930
+ }
7931
+ return c.json({
7932
+ window,
7933
+ metrics,
7934
+ previousMetrics,
7935
+ deltas,
7936
+ trend: {
7937
+ daily: trendDaily
7938
+ },
7939
+ thresholds,
7940
+ alerts
7941
+ });
7942
+ } catch (error) {
7943
+ return c.json({ error: error.message }, 500);
7944
+ } finally {
7945
+ await memoryService.shutdown();
7946
+ }
7947
+ });
7515
7948
  statsRouter.post("/graduation/run", async (c) => {
7516
7949
  const memoryService = getServiceFromQuery(c);
7517
7950
  try {
@@ -7759,19 +8192,19 @@ turnsRouter.post("/backfill", async (c) => {
7759
8192
 
7760
8193
  // src/server/api/projects.ts
7761
8194
  import { Hono as Hono7 } from "hono";
7762
- import * as fs5 from "fs";
7763
- import * as path5 from "path";
8195
+ import * as fs6 from "fs";
8196
+ import * as path6 from "path";
7764
8197
  import * as os3 from "os";
7765
8198
  var projectsRouter = new Hono7();
7766
8199
  projectsRouter.get("/", async (c) => {
7767
8200
  try {
7768
- const projectsDir = path5.join(os3.homedir(), ".claude-code", "memory", "projects");
7769
- if (!fs5.existsSync(projectsDir)) {
8201
+ const projectsDir = path6.join(os3.homedir(), ".claude-code", "memory", "projects");
8202
+ if (!fs6.existsSync(projectsDir)) {
7770
8203
  return c.json({ projects: [] });
7771
8204
  }
7772
- const projectHashes = fs5.readdirSync(projectsDir).filter((name) => {
7773
- const fullPath = path5.join(projectsDir, name);
7774
- return fs5.statSync(fullPath).isDirectory();
8205
+ const projectHashes = fs6.readdirSync(projectsDir).filter((name) => {
8206
+ const fullPath = path6.join(projectsDir, name);
8207
+ return fs6.statSync(fullPath).isDirectory();
7775
8208
  });
7776
8209
  const registry = loadSessionRegistry();
7777
8210
  const hashToPath = /* @__PURE__ */ new Map();
@@ -7781,17 +8214,17 @@ projectsRouter.get("/", async (c) => {
7781
8214
  }
7782
8215
  }
7783
8216
  const projects = projectHashes.map((hash) => {
7784
- const dirPath = path5.join(projectsDir, hash);
7785
- const dbPath = path5.join(dirPath, "events.sqlite");
8217
+ const dirPath = path6.join(projectsDir, hash);
8218
+ const dbPath = path6.join(dirPath, "events.sqlite");
7786
8219
  let dbSize = 0;
7787
- if (fs5.existsSync(dbPath)) {
7788
- dbSize = fs5.statSync(dbPath).size;
8220
+ if (fs6.existsSync(dbPath)) {
8221
+ dbSize = fs6.statSync(dbPath).size;
7789
8222
  }
7790
8223
  const projectPath = hashToPath.get(hash) || `unknown (${hash})`;
7791
8224
  return {
7792
8225
  hash,
7793
8226
  projectPath,
7794
- projectName: path5.basename(projectPath),
8227
+ projectName: path6.basename(projectPath),
7795
8228
  dbSize,
7796
8229
  dbSizeHuman: formatBytes(dbSize)
7797
8230
  };
@@ -7916,7 +8349,7 @@ function buildPrompt(statsContext, memoryContext, history, currentMessage) {
7916
8349
  return parts.join("\n");
7917
8350
  }
7918
8351
  function streamClaudeResponse(prompt, stream) {
7919
- return new Promise((resolve2, reject) => {
8352
+ return new Promise((resolve3, reject) => {
7920
8353
  const proc = spawn("claude", [
7921
8354
  "-p",
7922
8355
  "--output-format",
@@ -7988,7 +8421,7 @@ function streamClaudeResponse(prompt, stream) {
7988
8421
  if (code !== 0 && code !== null) {
7989
8422
  reject(new Error(`Claude CLI exited with code ${code}`));
7990
8423
  } else {
7991
- resolve2();
8424
+ resolve3();
7992
8425
  }
7993
8426
  });
7994
8427
  });