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
@@ -18,8 +18,8 @@ import { cors } from "hono/cors";
18
18
  import { logger } from "hono/logger";
19
19
  import { serve } from "@hono/node-server";
20
20
  import { serveStatic } from "@hono/node-server/serve-static";
21
- import * as path6 from "path";
22
- import * as fs6 from "fs";
21
+ import * as path7 from "path";
22
+ import * as fs7 from "fs";
23
23
 
24
24
  // src/server/api/index.ts
25
25
  import { Hono as Hono10 } from "hono";
@@ -92,57 +92,57 @@ function toDate(value) {
92
92
  return new Date(value);
93
93
  return new Date(String(value));
94
94
  }
95
- function createDatabase(path7, options) {
95
+ function createDatabase(path8, options) {
96
96
  if (options?.readOnly) {
97
- return new duckdb.Database(path7, { access_mode: "READ_ONLY" });
97
+ return new duckdb.Database(path8, { access_mode: "READ_ONLY" });
98
98
  }
99
- return new duckdb.Database(path7);
99
+ return new duckdb.Database(path8);
100
100
  }
101
101
  function dbRun(db, sql, params = []) {
102
- return new Promise((resolve2, reject) => {
102
+ return new Promise((resolve3, reject) => {
103
103
  if (params.length === 0) {
104
104
  db.run(sql, (err) => {
105
105
  if (err)
106
106
  reject(err);
107
107
  else
108
- resolve2();
108
+ resolve3();
109
109
  });
110
110
  } else {
111
111
  db.run(sql, ...params, (err) => {
112
112
  if (err)
113
113
  reject(err);
114
114
  else
115
- resolve2();
115
+ resolve3();
116
116
  });
117
117
  }
118
118
  });
119
119
  }
120
120
  function dbAll(db, sql, params = []) {
121
- return new Promise((resolve2, reject) => {
121
+ return new Promise((resolve3, reject) => {
122
122
  if (params.length === 0) {
123
123
  db.all(sql, (err, rows) => {
124
124
  if (err)
125
125
  reject(err);
126
126
  else
127
- resolve2(convertBigInts(rows || []));
127
+ resolve3(convertBigInts(rows || []));
128
128
  });
129
129
  } else {
130
130
  db.all(sql, ...params, (err, rows) => {
131
131
  if (err)
132
132
  reject(err);
133
133
  else
134
- resolve2(convertBigInts(rows || []));
134
+ resolve3(convertBigInts(rows || []));
135
135
  });
136
136
  }
137
137
  });
138
138
  }
139
139
  function dbClose(db) {
140
- return new Promise((resolve2, reject) => {
140
+ return new Promise((resolve3, reject) => {
141
141
  db.close((err) => {
142
142
  if (err)
143
143
  reject(err);
144
144
  else
145
- resolve2();
145
+ resolve3();
146
146
  });
147
147
  });
148
148
  }
@@ -780,12 +780,12 @@ import { randomUUID as randomUUID2 } from "crypto";
780
780
  import Database from "better-sqlite3";
781
781
  import * as fs from "fs";
782
782
  import * as nodePath from "path";
783
- function createSQLiteDatabase(path7, options) {
784
- const dir = nodePath.dirname(path7);
783
+ function createSQLiteDatabase(path8, options) {
784
+ const dir = nodePath.dirname(path8);
785
785
  if (!fs.existsSync(dir)) {
786
786
  fs.mkdirSync(dir, { recursive: true });
787
787
  }
788
- const db = new Database(path7, {
788
+ const db = new Database(path8, {
789
789
  readonly: options?.readonly ?? false
790
790
  });
791
791
  if (!options?.readonly && (options?.walMode ?? true)) {
@@ -1624,6 +1624,33 @@ var SQLiteEventStore = class {
1624
1624
  ids
1625
1625
  );
1626
1626
  }
1627
+ /**
1628
+ * Clear embedding outbox (used for embedding model migration)
1629
+ */
1630
+ async clearEmbeddingOutbox() {
1631
+ await this.initialize();
1632
+ sqliteRun(this.db, `DELETE FROM embedding_outbox`);
1633
+ }
1634
+ /**
1635
+ * Count total events
1636
+ */
1637
+ async countEvents() {
1638
+ await this.initialize();
1639
+ const row = sqliteGet(this.db, `SELECT COUNT(*) as count FROM events`);
1640
+ return row?.count || 0;
1641
+ }
1642
+ /**
1643
+ * Get events page in timestamp ascending order (stable migration/reindex scans)
1644
+ */
1645
+ async getEventsPage(limit = 1e3, offset = 0) {
1646
+ await this.initialize();
1647
+ const rows = sqliteAll(
1648
+ this.db,
1649
+ `SELECT * FROM events ORDER BY timestamp ASC LIMIT ? OFFSET ?`,
1650
+ [limit, offset]
1651
+ );
1652
+ return rows.map(this.rowToEvent);
1653
+ }
1627
1654
  /**
1628
1655
  * Mark outbox items as failed
1629
1656
  */
@@ -2431,7 +2458,7 @@ var SyncWorker = class {
2431
2458
  * Sleep utility
2432
2459
  */
2433
2460
  sleep(ms) {
2434
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2461
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
2435
2462
  }
2436
2463
  /**
2437
2464
  * Get sync statistics
@@ -2589,6 +2616,23 @@ var VectorStore = class {
2589
2616
  const result = await this.table.countRows();
2590
2617
  return result;
2591
2618
  }
2619
+ /**
2620
+ * Clear all vectors (used for embedding model migration)
2621
+ */
2622
+ async clearAll() {
2623
+ await this.initialize();
2624
+ if (!this.db)
2625
+ return;
2626
+ try {
2627
+ if (typeof this.db.dropTable === "function") {
2628
+ await this.db.dropTable(this.tableName);
2629
+ } else if (typeof this.db.drop_table === "function") {
2630
+ await this.db.drop_table(this.tableName);
2631
+ }
2632
+ } catch {
2633
+ }
2634
+ this.table = null;
2635
+ }
2592
2636
  /**
2593
2637
  * Check if vector exists for event
2594
2638
  */
@@ -2606,7 +2650,7 @@ var Embedder = class {
2606
2650
  pipeline = null;
2607
2651
  modelName;
2608
2652
  initialized = false;
2609
- constructor(modelName = "Xenova/all-MiniLM-L6-v2") {
2653
+ constructor(modelName = "jinaai/jina-embeddings-v5-text-nano") {
2610
2654
  this.modelName = modelName;
2611
2655
  }
2612
2656
  /**
@@ -2686,8 +2730,9 @@ var Embedder = class {
2686
2730
  };
2687
2731
  var defaultEmbedder = null;
2688
2732
  function getDefaultEmbedder() {
2733
+ const envModel = process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
2689
2734
  if (!defaultEmbedder) {
2690
- defaultEmbedder = new Embedder();
2735
+ defaultEmbedder = new Embedder(envModel || void 0);
2691
2736
  }
2692
2737
  return defaultEmbedder;
2693
2738
  }
@@ -3461,8 +3506,8 @@ _Context:_ ${sessionContext}`;
3461
3506
  matchesMetadataScope(metadata, expected) {
3462
3507
  if (!metadata)
3463
3508
  return false;
3464
- return Object.entries(expected).every(([path7, value]) => {
3465
- const actual = path7.split(".").reduce((acc, key) => {
3509
+ return Object.entries(expected).every(([path8, value]) => {
3510
+ const actual = path8.split(".").reduce((acc, key) => {
3466
3511
  if (typeof acc !== "object" || acc === null)
3467
3512
  return void 0;
3468
3513
  return acc[key];
@@ -5918,8 +5963,10 @@ var MemoryService = class {
5918
5963
  readOnly;
5919
5964
  lightweightMode;
5920
5965
  mdMirror;
5966
+ storagePath;
5921
5967
  constructor(config) {
5922
5968
  const storagePath = this.expandPath(config.storagePath);
5969
+ this.storagePath = storagePath;
5923
5970
  this.readOnly = config.readOnly ?? false;
5924
5971
  this.lightweightMode = config.lightweightMode ?? false;
5925
5972
  this.mdMirror = new MarkdownMirror2(process.cwd());
@@ -5955,7 +6002,8 @@ var MemoryService = class {
5955
6002
  );
5956
6003
  }
5957
6004
  this.vectorStore = new VectorStore(path3.join(storagePath, "vectors"));
5958
- this.embedder = config.embeddingModel ? new Embedder(config.embeddingModel) : getDefaultEmbedder();
6005
+ const embeddingModel = config.embeddingModel || process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
6006
+ this.embedder = embeddingModel ? new Embedder(embeddingModel) : getDefaultEmbedder();
5959
6007
  this.matcher = getDefaultMatcher();
5960
6008
  this.retriever = createRetriever(
5961
6009
  this.sqliteStore,
@@ -6899,6 +6947,95 @@ var MemoryService = class {
6899
6947
  recordMemoryAccess(eventId, sessionId, confidence = 1) {
6900
6948
  this.graduation.recordAccess(eventId, sessionId, confidence);
6901
6949
  }
6950
+ getEmbeddingModelName() {
6951
+ return this.embedder.getModelName();
6952
+ }
6953
+ /**
6954
+ * Ensure embedding model metadata is in sync and optionally migrate vectors.
6955
+ * Migration strategy: clear vector index + clear embedding outbox + re-enqueue all events.
6956
+ */
6957
+ async ensureEmbeddingModelForImport(options) {
6958
+ await this.initialize();
6959
+ const currentModel = this.getEmbeddingModelName();
6960
+ const metaPath = path3.join(this.storagePath, "embedding-meta.json");
6961
+ let previousModel = null;
6962
+ try {
6963
+ if (fs4.existsSync(metaPath)) {
6964
+ const parsed = JSON.parse(fs4.readFileSync(metaPath, "utf-8"));
6965
+ previousModel = parsed?.model || null;
6966
+ }
6967
+ } catch {
6968
+ previousModel = null;
6969
+ }
6970
+ const stats = await this.getStats();
6971
+ const hasExistingVectors = (stats.vectorCount || 0) > 0;
6972
+ if (!previousModel && !hasExistingVectors) {
6973
+ fs4.writeFileSync(metaPath, JSON.stringify({ model: currentModel, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
6974
+ return { changed: false, previousModel: null, currentModel, enqueued: 0, reason: "initialized-meta" };
6975
+ }
6976
+ const modelChanged = previousModel !== currentModel;
6977
+ const legacyUnknownButVectorsExist = !previousModel && hasExistingVectors;
6978
+ if (!modelChanged && !legacyUnknownButVectorsExist) {
6979
+ return { changed: false, previousModel, currentModel, enqueued: 0 };
6980
+ }
6981
+ if (options?.autoMigrate === false) {
6982
+ return {
6983
+ changed: true,
6984
+ previousModel,
6985
+ currentModel,
6986
+ enqueued: 0,
6987
+ reason: legacyUnknownButVectorsExist ? "legacy-vectors-without-meta" : "model-mismatch"
6988
+ };
6989
+ }
6990
+ const wasRunning = this.vectorWorker?.isRunning() || false;
6991
+ if (wasRunning)
6992
+ this.vectorWorker?.stop();
6993
+ await this.vectorStore.clearAll();
6994
+ await this.sqliteStore.clearEmbeddingOutbox();
6995
+ const pageSize = 1e3;
6996
+ let offset = 0;
6997
+ let enqueued = 0;
6998
+ while (true) {
6999
+ const page = await this.sqliteStore.getEventsPage(pageSize, offset);
7000
+ if (page.length === 0)
7001
+ break;
7002
+ for (const event of page) {
7003
+ await this.sqliteStore.enqueueForEmbedding(event.id, event.content);
7004
+ enqueued += 1;
7005
+ }
7006
+ offset += page.length;
7007
+ if (page.length < pageSize)
7008
+ break;
7009
+ }
7010
+ fs4.writeFileSync(
7011
+ metaPath,
7012
+ JSON.stringify(
7013
+ {
7014
+ model: currentModel,
7015
+ previousModel,
7016
+ migratedAt: (/* @__PURE__ */ new Date()).toISOString(),
7017
+ enqueued
7018
+ },
7019
+ null,
7020
+ 2
7021
+ )
7022
+ );
7023
+ if (wasRunning)
7024
+ this.vectorWorker?.start();
7025
+ return {
7026
+ changed: true,
7027
+ previousModel,
7028
+ currentModel,
7029
+ enqueued,
7030
+ reason: legacyUnknownButVectorsExist ? "legacy-vectors-without-meta" : "model-mismatch"
7031
+ };
7032
+ }
7033
+ /**
7034
+ * Backward-compatible alias used by some hooks
7035
+ */
7036
+ async close() {
7037
+ await this.shutdown();
7038
+ }
6902
7039
  /**
6903
7040
  * Shutdown service
6904
7041
  */
@@ -7077,6 +7214,7 @@ eventsRouter.get("/", async (c) => {
7077
7214
  const eventType = c.req.query("type");
7078
7215
  const level = c.req.query("level");
7079
7216
  const sort = c.req.query("sort") || "recent";
7217
+ const q = (c.req.query("q") || "").trim().toLowerCase();
7080
7218
  const limit = parseInt(c.req.query("limit") || "100", 10);
7081
7219
  const offset = parseInt(c.req.query("offset") || "0", 10);
7082
7220
  const memoryService = getServiceFromQuery(c);
@@ -7094,6 +7232,9 @@ eventsRouter.get("/", async (c) => {
7094
7232
  if (eventType) {
7095
7233
  events = events.filter((e) => e.eventType === eventType);
7096
7234
  }
7235
+ if (q) {
7236
+ events = events.filter((e) => (e.content || "").toLowerCase().includes(q));
7237
+ }
7097
7238
  if (sort === "accessed") {
7098
7239
  events.sort((a, b) => {
7099
7240
  const aTime = a.last_accessed_at || "";
@@ -7115,6 +7256,7 @@ eventsRouter.get("/", async (c) => {
7115
7256
  sessionId: e.sessionId,
7116
7257
  preview: e.content.slice(0, 200) + (e.content.length > 200 ? "..." : ""),
7117
7258
  contentLength: e.content.length,
7259
+ metadata: e.metadata,
7118
7260
  accessCount: e.access_count || 0,
7119
7261
  lastAccessedAt: e.last_accessed_at || null
7120
7262
  })),
@@ -7241,7 +7383,175 @@ searchRouter.get("/", async (c) => {
7241
7383
 
7242
7384
  // src/server/api/stats.ts
7243
7385
  import { Hono as Hono4 } from "hono";
7386
+ import * as fs5 from "fs";
7387
+ import * as path5 from "path";
7244
7388
  var statsRouter = new Hono4();
7389
+ var DEFAULT_KPI_THRESHOLDS = {
7390
+ usefulRecallRateMin: 0.45,
7391
+ reworkRateMax: 0.25,
7392
+ postChangeFailureRateMax: 0.2,
7393
+ avgCompletionTurnsMax: 12,
7394
+ memoryHitRateMin: 0.35
7395
+ };
7396
+ function loadKpiThresholds() {
7397
+ try {
7398
+ const filePath = path5.resolve(process.cwd(), "config", "kpi-thresholds.json");
7399
+ if (!fs5.existsSync(filePath))
7400
+ return DEFAULT_KPI_THRESHOLDS;
7401
+ const parsed = JSON.parse(fs5.readFileSync(filePath, "utf-8"));
7402
+ return {
7403
+ usefulRecallRateMin: Number(parsed.usefulRecallRateMin ?? DEFAULT_KPI_THRESHOLDS.usefulRecallRateMin),
7404
+ reworkRateMax: Number(parsed.reworkRateMax ?? DEFAULT_KPI_THRESHOLDS.reworkRateMax),
7405
+ postChangeFailureRateMax: Number(parsed.postChangeFailureRateMax ?? DEFAULT_KPI_THRESHOLDS.postChangeFailureRateMax),
7406
+ avgCompletionTurnsMax: Number(parsed.avgCompletionTurnsMax ?? DEFAULT_KPI_THRESHOLDS.avgCompletionTurnsMax),
7407
+ memoryHitRateMin: Number(parsed.memoryHitRateMin ?? DEFAULT_KPI_THRESHOLDS.memoryHitRateMin)
7408
+ };
7409
+ } catch {
7410
+ return DEFAULT_KPI_THRESHOLDS;
7411
+ }
7412
+ }
7413
+ function windowToMs(window) {
7414
+ if (window === "24h")
7415
+ return 24 * 60 * 60 * 1e3;
7416
+ if (window === "7d")
7417
+ return 7 * 24 * 60 * 60 * 1e3;
7418
+ return 30 * 24 * 60 * 60 * 1e3;
7419
+ }
7420
+ function inWindow(e, now, window) {
7421
+ return now - e.timestamp.getTime() <= windowToMs(window);
7422
+ }
7423
+ function isEditToolName(name) {
7424
+ return ["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name);
7425
+ }
7426
+ function parseToolPayload(e) {
7427
+ if (e.eventType !== "tool_observation")
7428
+ return null;
7429
+ try {
7430
+ const payload = JSON.parse(e.content);
7431
+ return {
7432
+ toolName: payload?.toolName,
7433
+ success: payload?.success,
7434
+ filePath: payload?.metadata?.filePath,
7435
+ command: payload?.metadata?.command
7436
+ };
7437
+ } catch {
7438
+ return {
7439
+ toolName: e.metadata?.toolName,
7440
+ success: e.metadata?.success,
7441
+ filePath: e.metadata?.filePath,
7442
+ command: e.metadata?.command
7443
+ };
7444
+ }
7445
+ }
7446
+ function isTestLikeCommand(command) {
7447
+ if (!command)
7448
+ return false;
7449
+ return /(test|jest|vitest|pytest|go test|cargo test|lint|eslint|build|tsc)/i.test(command);
7450
+ }
7451
+ function safeRatio(num, den) {
7452
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0)
7453
+ return 0;
7454
+ return num / den;
7455
+ }
7456
+ function round(value, digits = 4) {
7457
+ const factor = 10 ** digits;
7458
+ return Math.round(value * factor) / factor;
7459
+ }
7460
+ function computeSessionTurnCount(sessionEvents) {
7461
+ const turnIds = /* @__PURE__ */ new Set();
7462
+ for (const e of sessionEvents) {
7463
+ const turnId = e.metadata?.turnId;
7464
+ if (typeof turnId === "string" && turnId.length > 0)
7465
+ turnIds.add(turnId);
7466
+ }
7467
+ if (turnIds.size > 0)
7468
+ return turnIds.size;
7469
+ return sessionEvents.filter((e) => e.eventType === "user_prompt").length;
7470
+ }
7471
+ function computeKpiMetrics(events, usefulRecallRate) {
7472
+ const prompts = events.filter((e) => e.eventType === "user_prompt");
7473
+ const promptCount = prompts.length;
7474
+ const memoryHitPrompts = prompts.filter((p) => p.metadata?.adherence?.checked).length;
7475
+ const memoryHitRate = round(safeRatio(memoryHitPrompts, promptCount));
7476
+ const sessions = /* @__PURE__ */ new Map();
7477
+ for (const e of events) {
7478
+ const arr = sessions.get(e.sessionId) || [];
7479
+ arr.push(e);
7480
+ sessions.set(e.sessionId, arr);
7481
+ }
7482
+ let sessionTurnTotal = 0;
7483
+ let sessionTurnSamples = 0;
7484
+ let firstValidEditMinutesTotal = 0;
7485
+ let firstValidEditSamples = 0;
7486
+ for (const sessionEvents of sessions.values()) {
7487
+ sessionEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
7488
+ const turns = computeSessionTurnCount(sessionEvents);
7489
+ if (turns > 0) {
7490
+ sessionTurnTotal += turns;
7491
+ sessionTurnSamples++;
7492
+ }
7493
+ const firstPrompt = sessionEvents.find((e) => e.eventType === "user_prompt");
7494
+ const firstEdit = sessionEvents.find((e) => {
7495
+ const payload = parseToolPayload(e);
7496
+ return payload?.toolName && isEditToolName(payload.toolName) && payload.success === true;
7497
+ });
7498
+ if (firstPrompt && firstEdit) {
7499
+ const minutes = (firstEdit.timestamp.getTime() - firstPrompt.timestamp.getTime()) / 6e4;
7500
+ if (minutes >= 0) {
7501
+ firstValidEditMinutesTotal += minutes;
7502
+ firstValidEditSamples++;
7503
+ }
7504
+ }
7505
+ }
7506
+ const avgCompletionTurns = round(safeRatio(sessionTurnTotal, sessionTurnSamples), 2);
7507
+ const timeToFirstValidEditMinutes = round(safeRatio(firstValidEditMinutesTotal, firstValidEditSamples), 2);
7508
+ const editActions = [];
7509
+ let testRunsAfterEdit = 0;
7510
+ let failedTestRunsAfterEdit = 0;
7511
+ for (const [sessionId, sessionEvents] of sessions.entries()) {
7512
+ const sorted = [...sessionEvents].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
7513
+ let seenEdit = false;
7514
+ for (const e of sorted) {
7515
+ const payload = parseToolPayload(e);
7516
+ if (!payload?.toolName)
7517
+ continue;
7518
+ if (isEditToolName(payload.toolName) && payload.success === true) {
7519
+ editActions.push({ sessionId, timestamp: e.timestamp.getTime(), filePath: payload.filePath });
7520
+ seenEdit = true;
7521
+ continue;
7522
+ }
7523
+ if (seenEdit && isTestLikeCommand(payload.command)) {
7524
+ testRunsAfterEdit++;
7525
+ if (payload.success === false)
7526
+ failedTestRunsAfterEdit++;
7527
+ }
7528
+ }
7529
+ }
7530
+ const THIRTY_MIN_MS = 30 * 60 * 1e3;
7531
+ let reworkCount = 0;
7532
+ const bySessionFile = /* @__PURE__ */ new Map();
7533
+ const sortedEdits = [...editActions].sort((a, b) => a.timestamp - b.timestamp);
7534
+ for (const edit of sortedEdits) {
7535
+ if (!edit.filePath)
7536
+ continue;
7537
+ const key = `${edit.sessionId}::${edit.filePath}`;
7538
+ const prev = bySessionFile.get(key);
7539
+ if (typeof prev === "number" && edit.timestamp - prev <= THIRTY_MIN_MS) {
7540
+ reworkCount++;
7541
+ }
7542
+ bySessionFile.set(key, edit.timestamp);
7543
+ }
7544
+ const reworkRate = round(safeRatio(reworkCount, editActions.length));
7545
+ const postChangeFailureRate = round(safeRatio(failedTestRunsAfterEdit, testRunsAfterEdit));
7546
+ return {
7547
+ memoryHitRate,
7548
+ usefulRecallRate,
7549
+ avgCompletionTurns,
7550
+ timeToFirstValidEditMinutes,
7551
+ reworkRate,
7552
+ postChangeFailureRate
7553
+ };
7554
+ }
7245
7555
  statsRouter.get("/shared", async (c) => {
7246
7556
  const memoryService = getServiceFromQuery(c);
7247
7557
  try {
@@ -7521,6 +7831,129 @@ statsRouter.get("/retrieval-traces", async (c) => {
7521
7831
  await memoryService.shutdown();
7522
7832
  }
7523
7833
  });
7834
+ statsRouter.get("/kpi", async (c) => {
7835
+ const rawWindow = c.req.query("window") || "7d";
7836
+ const window = rawWindow === "24h" || rawWindow === "30d" ? rawWindow : "7d";
7837
+ const memoryService = getServiceFromQuery(c);
7838
+ try {
7839
+ await memoryService.initialize();
7840
+ const now = Date.now();
7841
+ const thresholds = loadKpiThresholds();
7842
+ const allEvents = await memoryService.getRecentEvents(2e4);
7843
+ const events = allEvents.filter((e) => inWindow(e, now, window));
7844
+ const helpfulness = await memoryService.getHelpfulnessStats();
7845
+ const usefulRecallRate = helpfulness.totalEvaluated > 0 ? round(safeRatio(helpfulness.helpful, helpfulness.totalEvaluated)) : 0;
7846
+ const metrics = computeKpiMetrics(events, usefulRecallRate);
7847
+ const windowMs = windowToMs(window);
7848
+ const prevEvents = allEvents.filter((e) => {
7849
+ const age = now - e.timestamp.getTime();
7850
+ return age > windowMs && age <= windowMs * 2;
7851
+ });
7852
+ const previousMetrics = computeKpiMetrics(prevEvents, usefulRecallRate);
7853
+ const deltas = {
7854
+ memoryHitRate: round(metrics.memoryHitRate - previousMetrics.memoryHitRate),
7855
+ usefulRecallRate: round(metrics.usefulRecallRate - previousMetrics.usefulRecallRate),
7856
+ avgCompletionTurns: round(metrics.avgCompletionTurns - previousMetrics.avgCompletionTurns, 2),
7857
+ timeToFirstValidEditMinutes: round(metrics.timeToFirstValidEditMinutes - previousMetrics.timeToFirstValidEditMinutes, 2),
7858
+ reworkRate: round(metrics.reworkRate - previousMetrics.reworkRate),
7859
+ postChangeFailureRate: round(metrics.postChangeFailureRate - previousMetrics.postChangeFailureRate)
7860
+ };
7861
+ const THIRTY_MIN_MS = 30 * 60 * 1e3;
7862
+ const trendWindowMs = 30 * 24 * 60 * 60 * 1e3;
7863
+ const trendEvents = allEvents.filter((e) => now - e.timestamp.getTime() <= trendWindowMs);
7864
+ const buckets = /* @__PURE__ */ new Map();
7865
+ for (const e of trendEvents) {
7866
+ const day = e.timestamp.toISOString().split("T")[0];
7867
+ const arr = buckets.get(day) || [];
7868
+ arr.push(e);
7869
+ buckets.set(day, arr);
7870
+ }
7871
+ const trendDaily = Array.from(buckets.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([date, dayEvents]) => {
7872
+ const dayPrompts = dayEvents.filter((e) => e.eventType === "user_prompt");
7873
+ const dayPromptCount = dayPrompts.length;
7874
+ const dayMemoryHit = dayPrompts.filter((p) => p.metadata?.adherence?.checked).length;
7875
+ const dayEdits = dayEvents.filter((e) => {
7876
+ const p = parseToolPayload(e);
7877
+ return Boolean(p?.toolName && isEditToolName(p.toolName) && p.success === true);
7878
+ });
7879
+ const dayEditActions = dayEdits.map((e) => {
7880
+ const p = parseToolPayload(e);
7881
+ return { sessionId: e.sessionId, timestamp: e.timestamp.getTime(), filePath: p?.filePath };
7882
+ }).filter((x) => Boolean(x.filePath));
7883
+ let dayReworkCount = 0;
7884
+ const dayBySessionFile = /* @__PURE__ */ new Map();
7885
+ for (const edit of dayEditActions) {
7886
+ const key = `${edit.sessionId}::${edit.filePath}`;
7887
+ const prev = dayBySessionFile.get(key);
7888
+ if (typeof prev === "number" && edit.timestamp - prev <= THIRTY_MIN_MS)
7889
+ dayReworkCount++;
7890
+ dayBySessionFile.set(key, edit.timestamp);
7891
+ }
7892
+ const dayTests = dayEvents.filter((e) => {
7893
+ const p = parseToolPayload(e);
7894
+ return Boolean(p?.toolName && isTestLikeCommand(p.command));
7895
+ });
7896
+ const dayFailedTests = dayEvents.filter((e) => {
7897
+ const p = parseToolPayload(e);
7898
+ return Boolean(p?.toolName && isTestLikeCommand(p.command) && p.success === false);
7899
+ });
7900
+ const turnsBySession = /* @__PURE__ */ new Map();
7901
+ for (const e of dayEvents) {
7902
+ const arr = turnsBySession.get(e.sessionId) || [];
7903
+ arr.push(e);
7904
+ turnsBySession.set(e.sessionId, arr);
7905
+ }
7906
+ let dayTurnsTotal = 0;
7907
+ let dayTurnsSamples = 0;
7908
+ for (const sessionEvents of turnsBySession.values()) {
7909
+ const turns = computeSessionTurnCount(sessionEvents);
7910
+ if (turns > 0) {
7911
+ dayTurnsTotal += turns;
7912
+ dayTurnsSamples++;
7913
+ }
7914
+ }
7915
+ return {
7916
+ date,
7917
+ memoryHitRate: round(safeRatio(dayMemoryHit, dayPromptCount)),
7918
+ usefulRecallRate,
7919
+ reworkRate: round(safeRatio(dayReworkCount, dayEditActions.length)),
7920
+ postChangeFailureRate: round(safeRatio(dayFailedTests.length, dayTests.length)),
7921
+ avgCompletionTurns: round(safeRatio(dayTurnsTotal, dayTurnsSamples), 2)
7922
+ };
7923
+ });
7924
+ const alerts = [];
7925
+ if (metrics.usefulRecallRate < thresholds.usefulRecallRateMin) {
7926
+ alerts.push({ metric: "usefulRecallRate", level: "warn", message: "Useful recall rate is below threshold", value: metrics.usefulRecallRate, threshold: thresholds.usefulRecallRateMin });
7927
+ }
7928
+ if (metrics.reworkRate > thresholds.reworkRateMax) {
7929
+ alerts.push({ metric: "reworkRate", level: "warn", message: "Rework rate is above threshold", value: metrics.reworkRate, threshold: thresholds.reworkRateMax });
7930
+ }
7931
+ if (metrics.postChangeFailureRate > thresholds.postChangeFailureRateMax) {
7932
+ alerts.push({ metric: "postChangeFailureRate", level: "warn", message: "Post-change failure rate is above threshold", value: metrics.postChangeFailureRate, threshold: thresholds.postChangeFailureRateMax });
7933
+ }
7934
+ if (metrics.avgCompletionTurns > thresholds.avgCompletionTurnsMax) {
7935
+ alerts.push({ metric: "avgCompletionTurns", level: "warn", message: "Average completion turns is above threshold", value: metrics.avgCompletionTurns, threshold: thresholds.avgCompletionTurnsMax });
7936
+ }
7937
+ if (metrics.memoryHitRate < thresholds.memoryHitRateMin) {
7938
+ alerts.push({ metric: "memoryHitRate", level: "warn", message: "Memory hit rate is below threshold", value: metrics.memoryHitRate, threshold: thresholds.memoryHitRateMin });
7939
+ }
7940
+ return c.json({
7941
+ window,
7942
+ metrics,
7943
+ previousMetrics,
7944
+ deltas,
7945
+ trend: {
7946
+ daily: trendDaily
7947
+ },
7948
+ thresholds,
7949
+ alerts
7950
+ });
7951
+ } catch (error) {
7952
+ return c.json({ error: error.message }, 500);
7953
+ } finally {
7954
+ await memoryService.shutdown();
7955
+ }
7956
+ });
7524
7957
  statsRouter.post("/graduation/run", async (c) => {
7525
7958
  const memoryService = getServiceFromQuery(c);
7526
7959
  try {
@@ -7768,19 +8201,19 @@ turnsRouter.post("/backfill", async (c) => {
7768
8201
 
7769
8202
  // src/server/api/projects.ts
7770
8203
  import { Hono as Hono7 } from "hono";
7771
- import * as fs5 from "fs";
7772
- import * as path5 from "path";
8204
+ import * as fs6 from "fs";
8205
+ import * as path6 from "path";
7773
8206
  import * as os3 from "os";
7774
8207
  var projectsRouter = new Hono7();
7775
8208
  projectsRouter.get("/", async (c) => {
7776
8209
  try {
7777
- const projectsDir = path5.join(os3.homedir(), ".claude-code", "memory", "projects");
7778
- if (!fs5.existsSync(projectsDir)) {
8210
+ const projectsDir = path6.join(os3.homedir(), ".claude-code", "memory", "projects");
8211
+ if (!fs6.existsSync(projectsDir)) {
7779
8212
  return c.json({ projects: [] });
7780
8213
  }
7781
- const projectHashes = fs5.readdirSync(projectsDir).filter((name) => {
7782
- const fullPath = path5.join(projectsDir, name);
7783
- return fs5.statSync(fullPath).isDirectory();
8214
+ const projectHashes = fs6.readdirSync(projectsDir).filter((name) => {
8215
+ const fullPath = path6.join(projectsDir, name);
8216
+ return fs6.statSync(fullPath).isDirectory();
7784
8217
  });
7785
8218
  const registry = loadSessionRegistry();
7786
8219
  const hashToPath = /* @__PURE__ */ new Map();
@@ -7790,17 +8223,17 @@ projectsRouter.get("/", async (c) => {
7790
8223
  }
7791
8224
  }
7792
8225
  const projects = projectHashes.map((hash) => {
7793
- const dirPath = path5.join(projectsDir, hash);
7794
- const dbPath = path5.join(dirPath, "events.sqlite");
8226
+ const dirPath = path6.join(projectsDir, hash);
8227
+ const dbPath = path6.join(dirPath, "events.sqlite");
7795
8228
  let dbSize = 0;
7796
- if (fs5.existsSync(dbPath)) {
7797
- dbSize = fs5.statSync(dbPath).size;
8229
+ if (fs6.existsSync(dbPath)) {
8230
+ dbSize = fs6.statSync(dbPath).size;
7798
8231
  }
7799
8232
  const projectPath = hashToPath.get(hash) || `unknown (${hash})`;
7800
8233
  return {
7801
8234
  hash,
7802
8235
  projectPath,
7803
- projectName: path5.basename(projectPath),
8236
+ projectName: path6.basename(projectPath),
7804
8237
  dbSize,
7805
8238
  dbSizeHuman: formatBytes(dbSize)
7806
8239
  };
@@ -7925,7 +8358,7 @@ function buildPrompt(statsContext, memoryContext, history, currentMessage) {
7925
8358
  return parts.join("\n");
7926
8359
  }
7927
8360
  function streamClaudeResponse(prompt, stream) {
7928
- return new Promise((resolve2, reject) => {
8361
+ return new Promise((resolve3, reject) => {
7929
8362
  const proc = spawn("claude", [
7930
8363
  "-p",
7931
8364
  "--output-format",
@@ -7997,7 +8430,7 @@ function streamClaudeResponse(prompt, stream) {
7997
8430
  if (code !== 0 && code !== null) {
7998
8431
  reject(new Error(`Claude CLI exited with code ${code}`));
7999
8432
  } else {
8000
- resolve2();
8433
+ resolve3();
8001
8434
  }
8002
8435
  });
8003
8436
  });
@@ -8054,14 +8487,14 @@ app.use("/*", cors());
8054
8487
  app.use("/*", logger());
8055
8488
  app.route("/api", apiRouter);
8056
8489
  app.get("/health", (c) => c.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
8057
- var uiPath = path6.join(__dirname, "../../dist/ui");
8058
- if (fs6.existsSync(uiPath)) {
8490
+ var uiPath = path7.join(__dirname, "../../dist/ui");
8491
+ if (fs7.existsSync(uiPath)) {
8059
8492
  app.use("/*", serveStatic({ root: uiPath }));
8060
8493
  }
8061
8494
  app.get("*", (c) => {
8062
- const indexPath = path6.join(uiPath, "index.html");
8063
- if (fs6.existsSync(indexPath)) {
8064
- return c.html(fs6.readFileSync(indexPath, "utf-8"));
8495
+ const indexPath = path7.join(uiPath, "index.html");
8496
+ if (fs7.existsSync(indexPath)) {
8497
+ return c.html(fs7.readFileSync(indexPath, "utf-8"));
8065
8498
  }
8066
8499
  return c.text('UI not built. Run "npm run build:ui" first.', 404);
8067
8500
  });