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.
- package/config/kpi-thresholds.json +7 -0
- package/dist/cli/index.js +532 -79
- package/dist/cli/index.js.map +3 -3
- package/dist/core/index.js +49 -4
- package/dist/core/index.js.map +2 -2
- package/dist/hooks/post-tool-use.js +140 -3
- package/dist/hooks/post-tool-use.js.map +2 -2
- package/dist/hooks/session-end.js +140 -3
- package/dist/hooks/session-end.js.map +2 -2
- package/dist/hooks/session-start.js +140 -3
- package/dist/hooks/session-start.js.map +2 -2
- package/dist/hooks/stop.js +140 -3
- package/dist/hooks/stop.js.map +2 -2
- package/dist/hooks/user-prompt-submit.js +379 -34
- package/dist/hooks/user-prompt-submit.js.map +3 -3
- package/dist/server/api/index.js +467 -34
- package/dist/server/api/index.js.map +3 -3
- package/dist/server/index.js +474 -41
- package/dist/server/index.js.map +3 -3
- package/dist/services/memory-service.js +140 -3
- package/dist/services/memory-service.js.map +2 -2
- package/dist/ui/app.js +362 -4
- package/dist/ui/index.html +90 -0
- package/dist/ui/style.css +41 -0
- package/memory/_index.md +3 -0
- package/memory/agent_response/uncategorized/2026-03-03.md +14 -0
- package/memory/session_summary/uncategorized/2026-03-03.md +5 -0
- package/memory/tool_observation/uncategorized/2026-03-03.md +21 -0
- package/package.json +3 -2
- package/scripts/delete-unknown-projects.js +154 -0
- package/src/cli/index.ts +23 -1
- package/src/core/embedder.ts +3 -2
- package/src/core/sqlite-event-store.ts +32 -0
- package/src/core/types.ts +2 -2
- package/src/core/vector-store.ts +20 -0
- package/src/hooks/user-prompt-submit.ts +225 -29
- package/src/server/api/events.ts +7 -0
- package/src/server/api/stats.ts +346 -0
- package/src/services/memory-service.ts +119 -2
- package/src/ui/app.js +362 -4
- package/src/ui/index.html +90 -0
- package/src/ui/style.css +41 -0
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
22
|
-
import * as
|
|
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(
|
|
95
|
+
function createDatabase(path8, options) {
|
|
96
96
|
if (options?.readOnly) {
|
|
97
|
-
return new duckdb.Database(
|
|
97
|
+
return new duckdb.Database(path8, { access_mode: "READ_ONLY" });
|
|
98
98
|
}
|
|
99
|
-
return new duckdb.Database(
|
|
99
|
+
return new duckdb.Database(path8);
|
|
100
100
|
}
|
|
101
101
|
function dbRun(db, sql, params = []) {
|
|
102
|
-
return new Promise((
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
resolve3();
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
function dbAll(db, sql, params = []) {
|
|
121
|
-
return new Promise((
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
resolve3(convertBigInts(rows || []));
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
function dbClose(db) {
|
|
140
|
-
return new Promise((
|
|
140
|
+
return new Promise((resolve3, reject) => {
|
|
141
141
|
db.close((err) => {
|
|
142
142
|
if (err)
|
|
143
143
|
reject(err);
|
|
144
144
|
else
|
|
145
|
-
|
|
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(
|
|
784
|
-
const dir = nodePath.dirname(
|
|
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(
|
|
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((
|
|
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 = "
|
|
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(([
|
|
3465
|
-
const actual =
|
|
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
|
-
|
|
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
|
|
7772
|
-
import * as
|
|
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 =
|
|
7778
|
-
if (!
|
|
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 =
|
|
7782
|
-
const fullPath =
|
|
7783
|
-
return
|
|
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 =
|
|
7794
|
-
const dbPath =
|
|
8226
|
+
const dirPath = path6.join(projectsDir, hash);
|
|
8227
|
+
const dbPath = path6.join(dirPath, "events.sqlite");
|
|
7795
8228
|
let dbSize = 0;
|
|
7796
|
-
if (
|
|
7797
|
-
dbSize =
|
|
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:
|
|
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((
|
|
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
|
-
|
|
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 =
|
|
8058
|
-
if (
|
|
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 =
|
|
8063
|
-
if (
|
|
8064
|
-
return c.html(
|
|
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
|
});
|