@plumpslabs/kuma 2.3.31 → 2.3.32
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/dist/index.js +1245 -508
- package/package.json +1 -1
- package/packages/ide/studio/dist/index.js +276 -97
- package/packages/ide/studio/public/index.html +141 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plumpslabs/kuma",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.32",
|
|
4
4
|
"description": "Safety-first context & orchestration engine for AI coding agents. MCP server with mandatory research pipeline, knowledge graph, impact analysis, decision memory, and safety guard — works with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -3,135 +3,310 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { serve } from "@hono/node-server";
|
|
5
5
|
import { readFileSync } from "fs";
|
|
6
|
-
import { join
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { spawn } from "child_process";
|
|
9
9
|
|
|
10
10
|
// src/api.ts
|
|
11
11
|
import { Hono } from "hono";
|
|
12
|
-
import { cors } from "hono/cors";
|
|
13
12
|
|
|
14
13
|
// src/db.ts
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { createRequire } from "module";
|
|
17
|
+
var require2 = createRequire(import.meta.url);
|
|
18
|
+
var sqlJs = null;
|
|
19
|
+
async function getSqlJs() {
|
|
20
|
+
if (!sqlJs) {
|
|
21
|
+
sqlJs = await require2("sql.js");
|
|
22
|
+
}
|
|
23
|
+
return sqlJs;
|
|
24
|
+
}
|
|
18
25
|
function findKumaDb(startDir) {
|
|
19
|
-
let current = startDir ? resolve(startDir) : process.cwd();
|
|
26
|
+
let current = startDir ? path.resolve(startDir) : process.cwd();
|
|
20
27
|
for (let i = 0; i < 20; i++) {
|
|
21
|
-
const candidate = join(current, ".kuma", "kuma.db");
|
|
22
|
-
if (existsSync(candidate)) return candidate;
|
|
23
|
-
const parent = resolve(current, "..");
|
|
28
|
+
const candidate = path.join(current, ".kuma", "kuma.db");
|
|
29
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
30
|
+
const parent = path.resolve(current, "..");
|
|
24
31
|
if (parent === current) break;
|
|
25
32
|
current = parent;
|
|
26
33
|
}
|
|
27
34
|
return null;
|
|
28
35
|
}
|
|
29
|
-
function
|
|
36
|
+
function projectRootFromDb(dbPath) {
|
|
37
|
+
return path.dirname(path.dirname(dbPath));
|
|
38
|
+
}
|
|
39
|
+
async function openDb() {
|
|
40
|
+
const SQL = await getSqlJs();
|
|
30
41
|
const dbPath = findKumaDb();
|
|
31
42
|
if (!dbPath) throw new Error("No .kuma/kuma.db found");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
maxBuffer: 2 * 1024 * 1024,
|
|
35
|
-
timeout: 1e4
|
|
36
|
-
}).trim();
|
|
43
|
+
const buffer = fs.readFileSync(dbPath);
|
|
44
|
+
return new SQL.Database(buffer);
|
|
37
45
|
}
|
|
38
|
-
function
|
|
39
|
-
const
|
|
40
|
-
|
|
46
|
+
function readSessionMetrics(dbPath) {
|
|
47
|
+
const empty = {
|
|
48
|
+
metrics: { filesRead: 0, filesEdited: 0, researchTimeSaved: 0 },
|
|
49
|
+
recordings: { archFlows: 0, gotchas: 0, decisions: 0, researchSaves: 0, total: 0 }
|
|
50
|
+
};
|
|
41
51
|
try {
|
|
42
|
-
|
|
52
|
+
const root = projectRootFromDb(dbPath);
|
|
53
|
+
const memPath = path.join(root, ".kuma", "memory.json");
|
|
54
|
+
if (!fs.existsSync(memPath)) return empty;
|
|
55
|
+
const parsed = JSON.parse(fs.readFileSync(memPath, "utf-8"));
|
|
56
|
+
return {
|
|
57
|
+
metrics: {
|
|
58
|
+
filesRead: parsed?.metrics?.filesRead ?? 0,
|
|
59
|
+
filesEdited: parsed?.metrics?.filesEdited ?? 0,
|
|
60
|
+
researchTimeSaved: parsed?.metrics?.researchTimeSaved ?? 0
|
|
61
|
+
},
|
|
62
|
+
recordings: {
|
|
63
|
+
archFlows: parsed?.recordings?.archFlows ?? 0,
|
|
64
|
+
gotchas: parsed?.recordings?.gotchas ?? 0,
|
|
65
|
+
decisions: parsed?.recordings?.decisions ?? 0,
|
|
66
|
+
researchSaves: parsed?.recordings?.researchSaves ?? 0,
|
|
67
|
+
total: parsed?.recordings?.total ?? 0
|
|
68
|
+
}
|
|
69
|
+
};
|
|
43
70
|
} catch {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
71
|
+
return empty;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function detectStaleNodes(db, dbPath) {
|
|
75
|
+
try {
|
|
76
|
+
const root = projectRootFromDb(dbPath);
|
|
77
|
+
const stmt = db.prepare(
|
|
78
|
+
`SELECT file_path, COUNT(*) as cnt FROM nodes
|
|
79
|
+
WHERE file_path IS NOT NULL AND file_path != '' AND file_path NOT LIKE '%::%'
|
|
80
|
+
GROUP BY file_path ORDER BY cnt DESC LIMIT 500`
|
|
81
|
+
);
|
|
82
|
+
const missing = [];
|
|
83
|
+
let checked = 0;
|
|
84
|
+
let staleNodes = 0;
|
|
85
|
+
while (stmt.step()) {
|
|
86
|
+
const row = stmt.getAsObject();
|
|
87
|
+
const fp = row.file_path;
|
|
88
|
+
checked++;
|
|
89
|
+
if (!fs.existsSync(path.join(root, fp))) {
|
|
90
|
+
const count = Number(row.cnt ?? 1);
|
|
91
|
+
missing.push({ filePath: fp, nodeCount: count });
|
|
92
|
+
staleNodes += count;
|
|
93
|
+
}
|
|
50
94
|
}
|
|
95
|
+
stmt.free();
|
|
96
|
+
return { checked, staleNodes, missing };
|
|
97
|
+
} catch {
|
|
98
|
+
return { checked: 0, staleNodes: 0, missing: [] };
|
|
51
99
|
}
|
|
52
100
|
}
|
|
53
|
-
function getDashboardData() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
101
|
+
async function getDashboardData() {
|
|
102
|
+
const db = await openDb();
|
|
103
|
+
const dbPath = findKumaDb();
|
|
104
|
+
try {
|
|
105
|
+
const run = (sql) => {
|
|
106
|
+
try {
|
|
107
|
+
const stmt = db.prepare(sql);
|
|
108
|
+
const rows = [];
|
|
109
|
+
while (stmt.step()) rows.push(stmt.getAsObject());
|
|
110
|
+
stmt.free();
|
|
111
|
+
return rows;
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const first = (sql, key) => {
|
|
117
|
+
const rows = run(sql);
|
|
118
|
+
if (!rows.length) return 0;
|
|
119
|
+
const v = rows[0][key];
|
|
120
|
+
return Number(v ?? 0) || 0;
|
|
121
|
+
};
|
|
122
|
+
const jsonRows = (sql) => {
|
|
123
|
+
const rows = run(sql);
|
|
124
|
+
if (!rows.length) return [];
|
|
125
|
+
const key = Object.keys(rows[0])[0];
|
|
126
|
+
const raw = rows[0][key];
|
|
127
|
+
if (raw === null || raw === void 0) return [];
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(String(raw));
|
|
130
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const stats = {
|
|
136
|
+
node_count: first(`SELECT COUNT(*) as c FROM nodes`, "c"),
|
|
137
|
+
edge_count: first(`SELECT COUNT(*) as c FROM edges`, "c"),
|
|
138
|
+
gotcha_count: first(`SELECT COUNT(*) as c FROM known_gotchas`, "c"),
|
|
139
|
+
feature_count: first(`SELECT COUNT(*) as c FROM nodes WHERE type = 'feature'`, "c"),
|
|
140
|
+
health_score: first(
|
|
141
|
+
`SELECT COALESCE((SELECT score FROM health_snapshots ORDER BY created_at DESC LIMIT 1), 0) as c`,
|
|
142
|
+
"c"
|
|
62
143
|
)
|
|
63
|
-
|
|
64
|
-
nodes
|
|
144
|
+
};
|
|
145
|
+
const nodes = jsonRows(
|
|
65
146
|
`SELECT json_group_array(json_object('id',id,'name',name,'type',type,'file_path',file_path,'severity',COALESCE(severity,'medium'),'confidence',COALESCE(confidence,0.8),'metadata',COALESCE(metadata,'{}'))) FROM nodes ORDER BY updated_at DESC`
|
|
66
|
-
)
|
|
67
|
-
edges
|
|
147
|
+
);
|
|
148
|
+
const edges = jsonRows(
|
|
68
149
|
`SELECT json_group_array(json_object('source',source_id,'target',target_id,'relation',type,'weight',weight)) FROM edges`
|
|
69
|
-
)
|
|
70
|
-
gotchas
|
|
150
|
+
);
|
|
151
|
+
const gotchas = jsonRows(
|
|
71
152
|
`SELECT json_group_array(json_object('id',id,'file_path',file_path,'description',REPLACE(REPLACE(description,char(10),' '),char(13),''),'severity',severity,'workaround',REPLACE(REPLACE(COALESCE(workaround,''),char(10),' '),char(13),''),'added_by',COALESCE(added_by,'agent'),'created_at',created_at)) FROM known_gotchas ORDER BY severity DESC`
|
|
72
|
-
)
|
|
73
|
-
features
|
|
153
|
+
);
|
|
154
|
+
const features = jsonRows(
|
|
74
155
|
`SELECT json_group_array(json_object('id',id,'name',name,'metadata',COALESCE(metadata,'{}'))) FROM nodes WHERE type = 'feature' ORDER BY name`
|
|
75
|
-
)
|
|
76
|
-
health
|
|
156
|
+
);
|
|
157
|
+
const health = jsonRows(
|
|
77
158
|
`SELECT json_group_array(json_object('score',score,'summary',REPLACE(REPLACE(COALESCE(summary,''),char(10),' '),char(13),''),'risk_level',risk_level,'created_at',created_at)) FROM health_snapshots ORDER BY created_at DESC LIMIT 10`
|
|
78
|
-
)
|
|
79
|
-
|
|
159
|
+
);
|
|
160
|
+
const sessionMetrics = readSessionMetrics(dbPath);
|
|
161
|
+
const verifTotal = first(`SELECT COUNT(*) as c FROM verifications`, "c");
|
|
162
|
+
const verifPassed = first(`SELECT COUNT(*) as c FROM verifications WHERE passed = 1`, "c");
|
|
163
|
+
const recentSessions = run(
|
|
164
|
+
`SELECT started_at, COALESCE(goal,'') as goal, tool_calls, edits, rollbacks, failures, safety_score
|
|
165
|
+
FROM sessions ORDER BY started_at DESC LIMIT 8`
|
|
166
|
+
).map((r) => ({
|
|
167
|
+
startedAt: Number(r.started_at ?? 0),
|
|
168
|
+
goal: r.goal || "",
|
|
169
|
+
toolCalls: Number(r.tool_calls ?? 0),
|
|
170
|
+
edits: Number(r.edits ?? 0),
|
|
171
|
+
rollbacks: Number(r.rollbacks ?? 0),
|
|
172
|
+
failures: Number(r.failures ?? 0),
|
|
173
|
+
safetyScore: r.safety_score == null ? null : Number(r.safety_score)
|
|
174
|
+
}));
|
|
175
|
+
const efficiency = {
|
|
176
|
+
sessions: first(`SELECT COUNT(*) as c FROM sessions`, "c"),
|
|
177
|
+
toolCalls: first(`SELECT COUNT(*) as c FROM tool_calls`, "c"),
|
|
178
|
+
totalEdits: first(`SELECT COALESCE(SUM(edits),0) as c FROM sessions`, "c"),
|
|
179
|
+
totalRollbacks: first(`SELECT COALESCE(SUM(rollbacks),0) as c FROM sessions`, "c"),
|
|
180
|
+
totalFailures: first(`SELECT COALESCE(SUM(failures),0) as c FROM sessions`, "c"),
|
|
181
|
+
researchCacheScopes: first(`SELECT COUNT(*) as c FROM research_cache`, "c"),
|
|
182
|
+
decisions: first(`SELECT COUNT(*) as c FROM decision_log`, "c"),
|
|
183
|
+
verifications: verifTotal,
|
|
184
|
+
verificationPassRate: verifTotal > 0 ? Math.round(verifPassed / verifTotal * 100) : null,
|
|
185
|
+
metrics: sessionMetrics.metrics,
|
|
186
|
+
recordings: sessionMetrics.recordings,
|
|
187
|
+
recentSessions
|
|
188
|
+
};
|
|
189
|
+
const staleness = detectStaleNodes(db, dbPath);
|
|
190
|
+
return { stats, nodes, edges, gotchas, features, health, efficiency, staleness };
|
|
191
|
+
} finally {
|
|
192
|
+
db.close();
|
|
193
|
+
}
|
|
80
194
|
}
|
|
81
|
-
function getNodeDetail(nodeId) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
return JSON.parse(r);
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
}).filter(Boolean) : [];
|
|
112
|
-
const gotchaFilter = filePath ? `file_path = '${filePath}'` : `file_path = '${escaped}' OR file_path LIKE '%${shortName}%'`;
|
|
113
|
-
const rawGotcha = query(
|
|
114
|
-
`SELECT json_object('id',id,'file_path',file_path,'description',REPLACE(REPLACE(description,char(10),' '),char(13),''),'severity',severity,'workaround',REPLACE(REPLACE(COALESCE(workaround,''),char(10),' '),char(13),''),'added_by',COALESCE(added_by,'agent'),'created_at',created_at) FROM known_gotchas WHERE ${gotchaFilter}`
|
|
115
|
-
);
|
|
116
|
-
const gotchas = rawGotcha ? rawGotcha.split("\n").filter(Boolean).map((r) => {
|
|
195
|
+
async function getNodeDetail(nodeId) {
|
|
196
|
+
const db = await openDb();
|
|
197
|
+
try {
|
|
198
|
+
const run = (sql) => {
|
|
199
|
+
try {
|
|
200
|
+
const stmt = db.prepare(sql);
|
|
201
|
+
const rows = [];
|
|
202
|
+
while (stmt.step()) rows.push(stmt.getAsObject());
|
|
203
|
+
stmt.free();
|
|
204
|
+
return rows;
|
|
205
|
+
} catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const escaped = nodeId.replace(/'/g, "''");
|
|
210
|
+
const parts = nodeId.split("::");
|
|
211
|
+
const shortName = parts.pop()?.replace(/'/g, "''") || "";
|
|
212
|
+
const filePath = parts[0] === "gotcha" && parts.length >= 2 ? parts[1]?.replace(/'/g, "''") : "";
|
|
213
|
+
const scope = parts[0] === "research" && parts.length >= 2 ? parts.slice(1).join("::").replace(/'/g, "''") : "";
|
|
214
|
+
const nodeRows = run(
|
|
215
|
+
`SELECT json_object('id',id,'name',name,'type',type,'file_path',COALESCE(file_path,''),'metadata',COALESCE(metadata,'{}'),'severity',COALESCE(severity,'medium'),'confidence',COALESCE(confidence,0.8),'last_verified_at',last_verified_at,'created_at',created_at,'updated_at',updated_at) FROM nodes WHERE id = '${escaped}'`
|
|
216
|
+
);
|
|
217
|
+
if (!nodeRows.length) return null;
|
|
218
|
+
const rawNode = nodeRows[0][Object.keys(nodeRows[0])[0]];
|
|
219
|
+
let node = null;
|
|
117
220
|
try {
|
|
118
|
-
|
|
221
|
+
node = typeof rawNode === "string" ? JSON.parse(rawNode) : rawNode;
|
|
119
222
|
} catch {
|
|
120
223
|
return null;
|
|
121
224
|
}
|
|
122
|
-
|
|
123
|
-
|
|
225
|
+
if (!node) return null;
|
|
226
|
+
const parseRelation = (rows, label) => rows.map((r) => {
|
|
227
|
+
const out = {
|
|
228
|
+
relation: r.relation,
|
|
229
|
+
weight: r.weight
|
|
230
|
+
};
|
|
231
|
+
if (label === "out") {
|
|
232
|
+
out.target = r.target;
|
|
233
|
+
out.target_name = r.target_name;
|
|
234
|
+
out.target_type = r.target_type;
|
|
235
|
+
} else {
|
|
236
|
+
out.source = r.source;
|
|
237
|
+
out.source_name = r.source_name;
|
|
238
|
+
out.source_type = r.source_type;
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}).filter(Boolean);
|
|
242
|
+
const outgoing = parseRelation(
|
|
243
|
+
run(
|
|
244
|
+
`SELECT e.target_id as target, e.type as relation, e.weight, n.name as target_name, n.type as target_type
|
|
245
|
+
FROM edges e LEFT JOIN nodes n ON n.id = e.target_id
|
|
246
|
+
WHERE e.source_id = '${escaped}'`
|
|
247
|
+
),
|
|
248
|
+
"out"
|
|
249
|
+
);
|
|
250
|
+
const incoming = parseRelation(
|
|
251
|
+
run(
|
|
252
|
+
`SELECT e.source_id as source, e.type as relation, e.weight, n.name as source_name, n.type as source_type
|
|
253
|
+
FROM edges e LEFT JOIN nodes n ON n.id = e.source_id
|
|
254
|
+
WHERE e.target_id = '${escaped}'`
|
|
255
|
+
),
|
|
256
|
+
"in"
|
|
257
|
+
);
|
|
258
|
+
const gotchaFilter = filePath ? `file_path = '${filePath}'` : `file_path = '${escaped}' OR file_path LIKE '%${shortName}%'`;
|
|
259
|
+
const gotchaRows = run(
|
|
260
|
+
`SELECT json_object('id',id,'file_path',file_path,'description',REPLACE(REPLACE(description,char(10),' '),char(13),''),'severity',severity,'workaround',REPLACE(REPLACE(COALESCE(workaround,''),char(10),' '),char(13),''),'added_by',COALESCE(added_by,'agent'),'created_at',created_at) FROM known_gotchas WHERE ${gotchaFilter}`
|
|
261
|
+
);
|
|
262
|
+
const gotchas = gotchaRows.map((r) => {
|
|
263
|
+
const raw = r[Object.keys(r)[0]];
|
|
264
|
+
if (raw === null || raw === void 0) return null;
|
|
265
|
+
try {
|
|
266
|
+
return JSON.parse(String(raw));
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}).filter(Boolean);
|
|
271
|
+
return { node, outgoing, incoming, gotchas };
|
|
272
|
+
} finally {
|
|
273
|
+
db.close();
|
|
274
|
+
}
|
|
124
275
|
}
|
|
125
276
|
|
|
126
277
|
// src/api.ts
|
|
127
|
-
import
|
|
278
|
+
import fs2 from "fs";
|
|
128
279
|
var api = new Hono();
|
|
129
|
-
|
|
280
|
+
var LOCALHOST_ORIGINS = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
281
|
+
function isLocalhostOrigin(origin) {
|
|
282
|
+
if (!origin) return false;
|
|
283
|
+
return LOCALHOST_ORIGINS.test(origin);
|
|
284
|
+
}
|
|
285
|
+
api.use("/*", async (c, next) => {
|
|
286
|
+
const origin = c.req.header("origin");
|
|
287
|
+
if (origin && !isLocalhostOrigin(origin)) {
|
|
288
|
+
return c.json({ error: "Forbidden: cross-origin requests are not allowed." }, 403);
|
|
289
|
+
}
|
|
290
|
+
if (origin) {
|
|
291
|
+
c.header("Access-Control-Allow-Origin", origin);
|
|
292
|
+
c.header("Vary", "Origin");
|
|
293
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
294
|
+
c.header("Access-Control-Allow-Headers", "Content-Type");
|
|
295
|
+
}
|
|
296
|
+
await next();
|
|
297
|
+
});
|
|
298
|
+
function confirmGuard(c) {
|
|
299
|
+
const confirm = c.req.query("confirm");
|
|
300
|
+
if (confirm !== "1") {
|
|
301
|
+
return c.json({ ok: false, error: "Confirmation required: pass ?confirm=1" }, 400);
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
130
305
|
api.get("/status", (c) => {
|
|
131
306
|
const dbPath = findKumaDb();
|
|
132
307
|
let dbSize = "0 KB";
|
|
133
|
-
if (dbPath &&
|
|
134
|
-
const bytes =
|
|
308
|
+
if (dbPath && fs2.existsSync(dbPath)) {
|
|
309
|
+
const bytes = fs2.statSync(dbPath).size;
|
|
135
310
|
dbSize = bytes > 1024 * 1024 ? (bytes / (1024 * 1024)).toFixed(2) + " MB" : (bytes / 1024).toFixed(1) + " KB";
|
|
136
311
|
}
|
|
137
312
|
return c.json({
|
|
@@ -141,18 +316,18 @@ api.get("/status", (c) => {
|
|
|
141
316
|
project: dbPath ? dbPath.replace("/.kuma/kuma.db", "").split("/").pop() : null
|
|
142
317
|
});
|
|
143
318
|
});
|
|
144
|
-
api.get("/dashboard", (c) => {
|
|
319
|
+
api.get("/dashboard", async (c) => {
|
|
145
320
|
try {
|
|
146
|
-
const data = getDashboardData();
|
|
321
|
+
const data = await getDashboardData();
|
|
147
322
|
return c.json(data);
|
|
148
323
|
} catch (e) {
|
|
149
324
|
return c.json({ error: e.message }, 500);
|
|
150
325
|
}
|
|
151
326
|
});
|
|
152
|
-
api.get("/node/:id", (c) => {
|
|
327
|
+
api.get("/node/:id", async (c) => {
|
|
153
328
|
try {
|
|
154
329
|
const nodeId = c.req.param("id");
|
|
155
|
-
const detail = getNodeDetail(nodeId);
|
|
330
|
+
const detail = await getNodeDetail(nodeId);
|
|
156
331
|
if (!detail) return c.json({ error: "Node not found" }, 404);
|
|
157
332
|
return c.json(detail);
|
|
158
333
|
} catch (e) {
|
|
@@ -160,17 +335,21 @@ api.get("/node/:id", (c) => {
|
|
|
160
335
|
}
|
|
161
336
|
});
|
|
162
337
|
api.post("/reset-db", (c) => {
|
|
338
|
+
const blocked = confirmGuard(c);
|
|
339
|
+
if (blocked) return blocked;
|
|
163
340
|
try {
|
|
164
341
|
const dbPath = findKumaDb();
|
|
165
|
-
if (dbPath &&
|
|
166
|
-
|
|
342
|
+
if (dbPath && fs2.existsSync(dbPath)) {
|
|
343
|
+
fs2.unlinkSync(dbPath);
|
|
167
344
|
}
|
|
168
345
|
return c.json({ ok: true, message: "Kuma DB reset successfully" });
|
|
169
346
|
} catch (e) {
|
|
170
|
-
return c.json({ error: e.message }, 500);
|
|
347
|
+
return c.json({ ok: false, error: e.message }, 500);
|
|
171
348
|
}
|
|
172
349
|
});
|
|
173
350
|
api.post("/stop-server", (c) => {
|
|
351
|
+
const blocked = confirmGuard(c);
|
|
352
|
+
if (blocked) return blocked;
|
|
174
353
|
setTimeout(() => process.exit(0), 500);
|
|
175
354
|
return c.json({ ok: true, message: "Kuma Studio server shutting down" });
|
|
176
355
|
});
|
|
@@ -199,7 +378,7 @@ if (customDir) {
|
|
|
199
378
|
}
|
|
200
379
|
var app = new Hono2();
|
|
201
380
|
app.route("/api", api_default);
|
|
202
|
-
var htmlPath =
|
|
381
|
+
var htmlPath = join(__dirname, "..", "public", "index.html");
|
|
203
382
|
var htmlCache = null;
|
|
204
383
|
try {
|
|
205
384
|
htmlCache = readFileSync(htmlPath, "utf-8");
|
|
@@ -252,6 +252,8 @@ header .status .dot.off { background: var(--red); }
|
|
|
252
252
|
<div class="tab active" data-tab="features"><i data-lucide="star" style="width:13px;height:13px;vertical-align:middle;margin-right:3px"></i> Features</div>
|
|
253
253
|
<div class="tab" data-tab="gotchas"><i data-lucide="alert-triangle" style="width:13px;height:13px;vertical-align:middle;margin-right:3px"></i> Gotchas</div>
|
|
254
254
|
<div class="tab" data-tab="health"><i data-lucide="heart-pulse" style="width:13px;height:13px;vertical-align:middle;margin-right:3px"></i> Health</div>
|
|
255
|
+
<div class="tab" data-tab="efficiency"><i data-lucide="zap" style="width:13px;height:13px;vertical-align:middle;margin-right:3px"></i> Efficiency</div>
|
|
256
|
+
<div class="tab" data-tab="staleness"><i data-lucide="activity" style="width:13px;height:13px;vertical-align:middle;margin-right:3px"></i> Staleness</div>
|
|
255
257
|
</div>
|
|
256
258
|
<div class="tab-content" id="tab-content"></div>
|
|
257
259
|
</div>
|
|
@@ -273,7 +275,7 @@ async function resetKumaDb() {
|
|
|
273
275
|
var confirmation = prompt("⚠️ TYPE 'DELETE' TO CONFIRM RESETTING KUMA DB\nThis will permanently wipe all knowledge:");
|
|
274
276
|
if (confirmation !== "DELETE") { if (confirmation !== null) alert("Reset cancelled."); return; }
|
|
275
277
|
try {
|
|
276
|
-
var res = await fetch(API + "/reset-db", { method: "POST" }).then(function(r){return r.json()});
|
|
278
|
+
var res = await fetch(API + "/reset-db?confirm=1", { method: "POST" }).then(function(r){return r.json()});
|
|
277
279
|
if (res.ok) { alert("Kuma DB reset."); refetch(); }
|
|
278
280
|
} catch (e) { alert("Reset failed: " + e.message); }
|
|
279
281
|
}
|
|
@@ -281,7 +283,7 @@ async function resetKumaDb() {
|
|
|
281
283
|
async function stopKumaStudio() {
|
|
282
284
|
if (!confirm("Stop Kuma Studio?")) return;
|
|
283
285
|
try {
|
|
284
|
-
await fetch(API + "/stop-server", { method: "POST" });
|
|
286
|
+
await fetch(API + "/stop-server?confirm=1", { method: "POST" });
|
|
285
287
|
document.body.innerHTML = '<div style="display:flex;height:100vh;align-items:center;justify-content:center;color:#94a3b8;font-size:16px;">Server stopped. Close this tab.</div>';
|
|
286
288
|
} catch (e) { alert("Stop failed: " + e.message); }
|
|
287
289
|
}
|
|
@@ -336,6 +338,8 @@ async function init() {
|
|
|
336
338
|
renderFeatures(data);
|
|
337
339
|
renderGotchas(data);
|
|
338
340
|
renderHealth(data);
|
|
341
|
+
renderEfficiency(data);
|
|
342
|
+
renderStaleness(data);
|
|
339
343
|
setupSearch();
|
|
340
344
|
lucide.createIcons();
|
|
341
345
|
}
|
|
@@ -784,7 +788,7 @@ async function refetch() {
|
|
|
784
788
|
if (status.ok) { dot.className = 'dot on'; txt.textContent = status.project || 'connected'; } else { dot.className = 'dot off'; txt.textContent = 'no kuma.db found'; }
|
|
785
789
|
var data = await fetch(API+'/dashboard').then(function(r){return r.json()});
|
|
786
790
|
if (data.error) { showError(data.error); return; }
|
|
787
|
-
renderStats(data); renderGraph(data); renderFeatures(data); renderGotchas(data); renderHealth(data);
|
|
791
|
+
renderStats(data); renderGraph(data); renderFeatures(data); renderGotchas(data); renderHealth(data); renderEfficiency(data); renderStaleness(data);
|
|
788
792
|
lucide.createIcons();
|
|
789
793
|
} catch(e) { showError('Refetch failed: ' + e.message); }
|
|
790
794
|
btn.innerHTML = '<i data-lucide="refresh-cw" style="width:13px;height:13px;vertical-align:middle;margin-right:3px"></i> Refetch';
|
|
@@ -802,7 +806,9 @@ function setupTabs(){
|
|
|
802
806
|
content.innerHTML =
|
|
803
807
|
'<div class="panel active" id="panel-features"><div class="empty">Loading...</div></div>' +
|
|
804
808
|
'<div class="panel" id="panel-gotchas"><div class="empty">Loading...</div></div>' +
|
|
805
|
-
'<div class="panel" id="panel-health"><div class="empty">Loading...</div></div>'
|
|
809
|
+
'<div class="panel" id="panel-health"><div class="empty">Loading...</div></div>' +
|
|
810
|
+
'<div class="panel" id="panel-efficiency"><div class="empty">Loading...</div></div>' +
|
|
811
|
+
'<div class="panel" id="panel-staleness"><div class="empty">Loading...</div></div>';
|
|
806
812
|
document.querySelectorAll('.tab').forEach(function(tab){
|
|
807
813
|
tab.onclick = function(){
|
|
808
814
|
document.querySelectorAll('.tab').forEach(function(t){t.classList.remove('active')});
|
|
@@ -867,6 +873,137 @@ function renderHealth(data){
|
|
|
867
873
|
}).join('');
|
|
868
874
|
}
|
|
869
875
|
|
|
876
|
+
// ============ EFFICIENCY PANEL (GAP 1) ============
|
|
877
|
+
// Proves the philosophy: "the more you use Kuma, the more efficient it gets."
|
|
878
|
+
// Every saved decision / record is an asset — here is the evidence.
|
|
879
|
+
|
|
880
|
+
function fmtDuration(sec) {
|
|
881
|
+
if (!sec || sec <= 0) return "0s";
|
|
882
|
+
if (sec >= 3600) return (sec/3600).toFixed(1)+"h";
|
|
883
|
+
if (sec >= 60) return Math.round(sec/60)+"m "+(sec%60)+"s";
|
|
884
|
+
return sec+"s";
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function renderEfficiency(data){
|
|
888
|
+
var el = document.getElementById("panel-efficiency");
|
|
889
|
+
if(!el) return;
|
|
890
|
+
var eff = data.efficiency || {};
|
|
891
|
+
var m = eff.metrics || {};
|
|
892
|
+
var r = eff.recordings || {};
|
|
893
|
+
var savedSec = m.researchTimeSaved ? Math.round(m.researchTimeSaved/1000) : 0;
|
|
894
|
+
var passRate = eff.verificationPassRate;
|
|
895
|
+
var passColor = passRate == null ? 'var(--muted)' : passRate >= 80 ? 'var(--green)' : passRate >= 50 ? 'var(--yellow)' : 'var(--red)';
|
|
896
|
+
|
|
897
|
+
var stat = function(num, lbl, color){
|
|
898
|
+
return '<div style="flex:1;min-width:110px;padding:10px 12px;background:var(--bg);border-radius:8px;border:1px solid var(--border)"><div style="font-size:20px;font-weight:700;color:'+(color||'var(--accent2)')+'">'+num+'</div><div style="font-size:11px;color:var(--muted);margin-top:2px">'+lbl+'</div></div>';
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
var html = '';
|
|
902
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px">' +
|
|
903
|
+
stat(eff.sessions||0, 'sessions') +
|
|
904
|
+
stat(eff.toolCalls||0, 'tool calls') +
|
|
905
|
+
stat(eff.totalEdits||0, 'file edits') +
|
|
906
|
+
stat(eff.researchCacheScopes||0, 'research cached', 'var(--green)') +
|
|
907
|
+
stat(eff.decisions||0, 'decisions', 'var(--accent)') +
|
|
908
|
+
stat(eff.verifications||0, 'verifications', passRate==null?'var(--muted)':'var(--green)') +
|
|
909
|
+
'</div>';
|
|
910
|
+
|
|
911
|
+
// The headline: time saved from cache — the compounding asset
|
|
912
|
+
html += '<div class="health-item" style="border-left:3px solid var(--green);margin-bottom:12px">' +
|
|
913
|
+
'<div style="font-size:13px;color:var(--muted)">⏱ Estimated time saved from research cache</div>' +
|
|
914
|
+
'<div class="score good" style="font-size:28px">'+fmtDuration(savedSec)+'</div>' +
|
|
915
|
+
'<div class="meta">'+ (m.filesRead||0) +' files read · '+ (m.filesEdited||0) +' files edited (current session)</div>' +
|
|
916
|
+
'</div>';
|
|
917
|
+
|
|
918
|
+
// Recording assets — what we've banked
|
|
919
|
+
var recTotal = r.total||0;
|
|
920
|
+
html += '<div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px;margin:10px 0 6px">📦 Recorded Assets</div>';
|
|
921
|
+
html += '<div class="health-item" style="margin-bottom:12px">';
|
|
922
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:6px">';
|
|
923
|
+
html += '<span class="sev-tag" style="background:rgba(124,58,237,0.15);color:var(--accent)">arch_flow: '+(r.archFlows||0)+'</span>';
|
|
924
|
+
html += '<span class="sev-tag" style="background:#f9731620;color:var(--orange)">gotcha: '+(r.gotchas||0)+'</span>';
|
|
925
|
+
html += '<span class="sev-tag" style="background:#eab30820;color:var(--yellow)">decision: '+(r.decisions||0)+'</span>';
|
|
926
|
+
html += '<span class="sev-tag" style="background:#06b6d420;color:var(--accent2)">research_save: '+(r.researchSaves||0)+'</span>';
|
|
927
|
+
html += '</div>';
|
|
928
|
+
html += '<div class="meta" style="margin-top:6px">Total assets banked: <b style="color:var(--accent2)">'+recTotal+'</b> — each one saves re-research next session</div>';
|
|
929
|
+
html += '</div>';
|
|
930
|
+
|
|
931
|
+
// Verification pass rate
|
|
932
|
+
html += '<div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px;margin:10px 0 6px">🧪 Verification</div>';
|
|
933
|
+
html += '<div class="health-item" style="margin-bottom:12px">' +
|
|
934
|
+
'<div class="score" style="color:'+passColor+'">'+(passRate==null?'—':passRate+'%')+'</div>' +
|
|
935
|
+
'<div class="meta">'+(eff.verifications||0)+' runs · pass rate</div>' +
|
|
936
|
+
'</div>';
|
|
937
|
+
|
|
938
|
+
// Recent sessions breakdown
|
|
939
|
+
var sess = eff.recentSessions || [];
|
|
940
|
+
if (sess.length) {
|
|
941
|
+
html += '<div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px;margin:10px 0 6px">🕐 Recent Sessions</div>';
|
|
942
|
+
html += sess.map(function(s){
|
|
943
|
+
var d = s.startedAt ? new Date(s.startedAt*1000) : null;
|
|
944
|
+
var dateStr = d ? d.toLocaleDateString()+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) : '-';
|
|
945
|
+
return '<div class="health-item">' +
|
|
946
|
+
'<div style="font-size:12px;color:var(--text)">'+escapeHtml(s.goal || 'untitled goal')+'</div>' +
|
|
947
|
+
'<div class="meta">'+dateStr+' · '+s.toolCalls+' calls · '+s.edits+' edits'+(s.failures?' · <span style="color:var(--red)">'+s.failures+' failures</span>':'')+'</div>' +
|
|
948
|
+
'</div>';
|
|
949
|
+
}).join('');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
el.innerHTML = html;
|
|
953
|
+
lucide.createIcons();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ============ STALENESS PANEL (GAP 4) ============
|
|
957
|
+
// Assets can become liabilities when stale. Surface them before they bite.
|
|
958
|
+
|
|
959
|
+
function renderStaleness(data){
|
|
960
|
+
var el = document.getElementById("panel-staleness");
|
|
961
|
+
if(!el) return;
|
|
962
|
+
var st = data.staleness || { checked: 0, staleNodes: 0, missing: [] };
|
|
963
|
+
var html = '';
|
|
964
|
+
|
|
965
|
+
if (!st.staleNodes) {
|
|
966
|
+
html += '<div class="health-item" style="border-left:3px solid var(--green)">' +
|
|
967
|
+
'<div class="score good" style="font-size:22px">✅ All fresh</div>' +
|
|
968
|
+
'<div class="meta">'+st.checked+' file references checked — no stale assets found.</div>' +
|
|
969
|
+
'</div>';
|
|
970
|
+
} else {
|
|
971
|
+
html += '<div class="health-item" style="border-left:3px solid var(--red)">' +
|
|
972
|
+
'<div class="score bad" style="font-size:22px">'+st.staleNodes+' stale node(s)</div>' +
|
|
973
|
+
'<div class="meta">'+st.missing.length+' file(s) referenced but missing on disk ('+st.checked+' checked).</div>' +
|
|
974
|
+
'</div>';
|
|
975
|
+
html += '<div style="display:flex;gap:6px;margin:10px 0">' +
|
|
976
|
+
'<button onclick="copyHealCommand(this)" style="background:rgba(124,58,237,0.15);border:1px solid rgba(124,58,237,0.3);border-radius:6px;color:var(--accent);padding:6px 12px;font-size:11px;cursor:pointer">🛠 Copy heal command</button>' +
|
|
977
|
+
'</div>';
|
|
978
|
+
html += st.missing.map(function(m){
|
|
979
|
+
return '<div class="gotcha-item" style="border-color:var(--red)">' +
|
|
980
|
+
'<div class="file">'+escapeHtml(m.filePath)+'</div>' +
|
|
981
|
+
'<div class="desc">Referenced by <b style="color:var(--red)">'+m.nodeCount+'</b> node(s) but the file no longer exists.</div>' +
|
|
982
|
+
'</div>';
|
|
983
|
+
}).join('');
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
el.innerHTML = html;
|
|
987
|
+
lucide.createIcons();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function copyHealCommand(btn) {
|
|
991
|
+
var cmd = "kuma_memory({ action: 'heal' })";
|
|
992
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
993
|
+
navigator.clipboard.writeText(cmd).then(function() {
|
|
994
|
+
if (btn) { btn.textContent = "✅ Copied!"; setTimeout(function(){ btn.textContent = "🛠 Copy heal command"; }, 1500); }
|
|
995
|
+
}).catch(function() {
|
|
996
|
+
if (btn) { btn.textContent = "⚠️ Copy failed"; setTimeout(function(){ btn.textContent = "🛠 Copy heal command"; }, 1500); }
|
|
997
|
+
});
|
|
998
|
+
} else {
|
|
999
|
+
// Fallback for non-secure contexts
|
|
1000
|
+
var ta = document.createElement("textarea");
|
|
1001
|
+
ta.value = cmd; document.body.appendChild(ta); ta.select();
|
|
1002
|
+
try { document.execCommand("copy"); if (btn) { btn.textContent = "✅ Copied!"; setTimeout(function(){ btn.textContent = "🛠 Copy heal command"; }, 1500); } } catch(e) {}
|
|
1003
|
+
document.body.removeChild(ta);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
870
1007
|
function setupSearch() {
|
|
871
1008
|
var searchTimeout = null;
|
|
872
1009
|
document.getElementById("node-search").oninput = function() {
|