openclaw-inspector 1.0.1
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/LICENSE +21 -0
- package/README.md +136 -0
- package/danger-rules.json +110 -0
- package/dist/assets/index-Br9n3XxB.js +12 -0
- package/dist/assets/index-DJx37DEV.css +1 -0
- package/dist/favicon.svg +9 -0
- package/dist/index.html +14 -0
- package/package.json +68 -0
- package/server.js +472 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif;background:#fafafa;color:#1a1a1a;line-height:1.5}.container{display:flex;height:100vh}.sidebar{width:360px;background:#fff;border-right:1px solid #e5e5e5;overflow:hidden;display:flex;flex-direction:column;flex-shrink:0}.sidebar-header{padding:16px 20px;border-bottom:1px solid #e5e5e5;position:sticky;top:0;background:#fff;z-index:10}.sidebar-header h3{font-size:14px;font-weight:600;color:#666;text-transform:uppercase;letter-spacing:.5px}.sidebar-search{padding:12px 16px;border-bottom:1px solid #e5e5e5;position:sticky;top:52px;background:#fff;z-index:10}.sidebar-search input{width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:6px;font-size:13px;outline:none}.sidebar-search input:focus{border-color:#999}.sidebar-filters{padding:8px 16px;border-bottom:1px solid #e5e5e5;display:flex;flex-direction:column;gap:6px;position:sticky;top:96px;background:#fff;z-index:10}.filter-group{display:flex;flex-direction:column;gap:2px}.filter-label{font-size:10px;color:#999;text-transform:uppercase;letter-spacing:.5px}.filter-row{display:flex;gap:4px;flex-wrap:wrap;align-items:center}.sort-select{font-size:11px;padding:3px 8px;border:1px solid #ddd;border-radius:6px;background:#fff;color:#666;cursor:pointer;outline:none;margin-left:auto}.filter-btn{font-size:11px;padding:3px 10px;border-radius:12px;border:1px solid #ddd;background:#fff;cursor:pointer;color:#666;transition:all .15s}.filter-btn:hover{border-color:#999}.filter-btn.active{background:#4f46e5;color:#fff;border-color:#4f46e5}.session-list{flex:1;overflow:hidden;min-height:0}.session-item{padding:12px 20px;border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background .15s;position:relative}.session-item:hover{background:#f5f5f5}.session-item.selected{background:#eef2ff;border-left:3px solid #4f46e5;padding-left:17px}.session-item .name{font-size:13px;font-weight:600;color:#1a1a1a;margin-bottom:2px;display:flex;align-items:center;gap:6px}.session-item .label{font-size:13px;font-weight:500;color:#4f46e5}.session-item .desc{font-size:12px;color:#888;margin-top:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{display:inline-block;font-size:10px;padding:1px 6px;border-radius:10px;font-weight:500}.badge.active{background:#dcfce7;color:#166534}.badge.orphan{background:#fef3c7;color:#92400e}.badge.deleted{background:#fee2e2;color:#991b1b}.badge.superseded{background:#e0e7ff;color:#3730a3}.badge.unread{background:#dbeafe;color:#1e40af}.badge.danger{background:#fee2e2;color:#dc2626;font-weight:700}.badge.danger-warn{background:#fef3c7;color:#d97706;font-weight:700}.msg .bubble.has-danger{border:2px solid #dc2626;box-shadow:0 0 8px #dc262626}.msg .bubble.has-warning{border:2px solid #d97706;box-shadow:0 0 8px #d9770626}.danger-chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:3px 8px;border-radius:10px;margin:4px 2px}.danger-chip.critical{background:#fee2e2;color:#dc2626}.danger-chip.warning{background:#fef3c7;color:#d97706}.badge.read-done{background:#dcfce7;color:#166534}.badge.partial{background:#fef3c7;color:#92400e}.stats{padding:12px 20px;border-bottom:1px solid #e5e5e5;font-size:12px;color:#888;display:flex;gap:12px;flex-wrap:wrap}.stats span{white-space:nowrap;cursor:pointer}.main{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#fafafa;position:relative}.main-toolbar{padding:12px 24px;background:#fff;border-bottom:1px solid #e5e5e5}.toolbar-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.toolbar-top h2{font-size:15px;font-weight:600;flex-shrink:0}.toolbar-controls{display:flex;align-items:center;gap:8px;flex:1;justify-content:flex-end;flex-wrap:wrap}.toolbar-search{max-width:200px;padding:6px 10px;border:1px solid #ddd;border-radius:6px;font-size:12px;outline:none}.toolbar-search:focus{border-color:#999}.toolbar-meta{display:flex;align-items:center;gap:12px;margin-top:4px}.meta-filename{font-size:11px;color:#aaa;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.metadata-grid{display:flex;gap:24px;flex-wrap:wrap;padding-top:8px}.metadata-grid .item{font-size:12px}.metadata-grid .item .k{color:#888}.metadata-grid .item .v{font-weight:600;color:#1a1a1a}.meta-toggle{display:inline;font-size:12px;color:#4f46e5;cursor:pointer;background:none;border:none;padding:0;font-family:inherit}.meta-toggle:hover{text-decoration:underline}.expand-btn{font-size:12px;padding:6px 14px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;color:#666;transition:all .15s;white-space:nowrap}.expand-btn:hover{border-color:#999;background:#f5f5f5}.expand-btn.active{background:#4f46e5;color:#fff;border-color:#4f46e5}.msg-toggle{display:inline-flex;border:1px solid #ddd;border-radius:6px;overflow:hidden}.toggle-opt{font-size:12px;padding:6px 14px;border:none;background:#fff;cursor:pointer;color:#666;transition:all .15s;white-space:nowrap}.toggle-opt:not(:last-child){border-right:1px solid #ddd}.toggle-opt.active{background:#4f46e5;color:#fff}.toggle-opt[data-mode=danger].active{background:#dc2626;color:#fff}.empty{flex:1;display:flex;align-items:center;justify-content:center;color:#bbb;font-size:14px}.messages{flex:1;overflow:hidden;position:relative;min-height:0}.messages [data-virtuoso-scroller]{overflow-x:hidden!important}.messages [data-item-index]{padding:0 24px}.messages [data-item-index]:first-child{padding-top:20px}.messages [data-item-index]:last-child{padding-bottom:20px}.msg{margin-bottom:16px;cursor:pointer;transition:opacity .2s}.msg.read{opacity:.45}.msg:hover{opacity:1!important}.msg{position:relative}.msg-row{display:inline-flex;align-items:center;gap:12px;max-width:70%;overflow:hidden}.mark-read-btn{display:none;flex-shrink:0;border-radius:12px;border:1px solid #ddd;background:#fff;color:#4f46e5;font-size:11px;cursor:pointer;box-shadow:0 1px 4px #0000001a;transition:all .15s;padding:4px 8px;white-space:nowrap;font-family:inherit}.mark-read-btn:hover{background:#4f46e5;color:#fff;border-color:#4f46e5}.msg:hover .mark-read-btn{display:block}.msg.user{display:flex;justify-content:flex-end}.msg.assistant{display:flex;justify-content:flex-start}.bubble{padding:12px 16px;border-radius:12px;font-size:14px;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word}.msg.user .bubble{background:#4f46e5;color:#fff;border-bottom-right-radius:4px}.msg.assistant .bubble{background:#fff;border:1px solid #e5e5e5;color:#1a1a1a;border-bottom-left-radius:4px}.bubble .time{font-size:11px;opacity:.5;margin-bottom:4px}.bubble .content{white-space:pre-wrap;word-wrap:break-word;line-height:1.6}.bubble .content pre{background:#0000000d;padding:8px;border-radius:4px;overflow-x:auto;font-size:12px;margin:8px 0}.msg.user .bubble .content pre{background:#ffffff26}.read-marker{display:flex;align-items:center;gap:12px;margin:24px 0;color:#4f46e5;font-size:12px;font-weight:500}.read-marker:before,.read-marker:after{content:"";flex:1;height:1px;background:#4f46e5;opacity:.3}.tool-chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:4px 10px;border-radius:12px;background:#f0f0f0;color:#555;cursor:pointer;margin:4px 2px;transition:background .15s}.tool-chip:hover{background:#e5e5e5}.tool-chip.error{background:#fee2e2;color:#991b1b}.tool-detail{display:none;margin-top:8px;padding:10px;background:#f8f8f8;border-radius:6px;border:1px solid #eee;font-size:12px;max-height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}.tool-detail.open{display:block}.thinking-toggle{display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:4px 10px;border-radius:12px;background:#fef3c7;color:#92400e;cursor:pointer;margin:4px 0}.thinking-content{display:none;margin-top:8px;padding:10px;background:#fffbeb;border-radius:6px;border:1px solid #fde68a;font-size:12px;max-height:300px;overflow-y:auto;white-space:pre-wrap}.thinking-content.open{display:block}.sys-msg{text-align:center;padding:8px;font-size:12px;color:#999}.compaction-msg{margin:20px 0;padding:12px 16px;background:#fff7ed;border-left:3px solid #f97316;border-radius:4px;font-size:12px;color:#9a3412}.compaction-msg .title{font-weight:600;margin-bottom:4px}.compaction-msg .summary{color:#78350f;opacity:.8}.custom-msg{text-align:center;padding:4px;font-size:11px;color:#aaa}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#ddd;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#bbb}@keyframes fadeIn{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}@media(max-width:768px){.container{flex-direction:column}.sidebar{width:100%;position:absolute;z-index:20;height:100vh;display:none}.sidebar.mobile-open{display:flex}.main{width:100%}.mobile-toggle{display:block;position:fixed;bottom:20px;left:20px;z-index:30;width:44px;height:44px;border-radius:22px;background:#4f46e5;color:#fff;border:none;font-size:20px;cursor:pointer;box-shadow:0 2px 8px #00000026}.mobile-back{display:block}.sidebar-filters{flex-wrap:wrap;gap:4px}.filter-btn{font-size:10px;padding:2px 7px}.sort-select{font-size:10px;padding:2px 6px;width:100%;margin-top:4px}.toolbar-search{max-width:140px;font-size:11px}.toolbar-top{gap:8px}.msg-toggle{width:100%;display:flex}.toggle-opt{flex:1;font-size:11px;padding:6px 8px;text-align:center}.expand-btn{font-size:11px;padding:4px 10px}.bubble{max-width:95%!important;font-size:13px}.bubble .content pre{font-size:11px}.session-item{padding:10px 14px}.stats{font-size:11px;padding:6px 14px}.empty{font-size:14px;padding:20px}.sidebar-header{padding:12px 16px;display:flex;align-items:center;justify-content:space-between}.metadata-grid{font-size:11px;padding:4px 0}.toast{max-width:85vw;font-size:13px}.msg .bubble .content{font-size:13px}.msg .bubble .content pre{max-width:85vw;overflow-x:auto}.tool-chip{font-size:11px}.read-marker{font-size:11px;margin:8px 0}.msg-header{padding:8px 12px}.msg-header h2{font-size:15px;margin:0}.msg-header .filename{font-size:10px}.toolbar-search{max-width:120px}.expand-btn{font-size:10px;padding:4px 8px}.toggle-opt{font-size:10px;padding:4px 6px}}@media(min-width:769px){.mobile-toggle,.mobile-back{display:none}}.floating-btn{position:absolute;z-index:10;border:none;border-radius:20px;padding:8px 16px;font-size:13px;cursor:pointer;box-shadow:0 2px 8px #00000026;transition:opacity .2s,transform .2s}.floating-btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px #0003}.new-msg-btn{bottom:20px;right:20px;background:#4f46e5;color:#fff}.jump-btn{bottom:20px;left:50%;transform:translate(-50%);background:#f59e0b;color:#1a1a1a;font-weight:600}.jump-btn:hover{transform:translate(-50%) translateY(-1px)}
|
package/dist/favicon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<!-- Magnifying glass -->
|
|
3
|
+
<circle cx="24" cy="30" r="13" fill="none" stroke="#4f46e5" stroke-width="4"/>
|
|
4
|
+
<line x1="34" y1="40" x2="46" y2="52" stroke="#4f46e5" stroke-width="4" stroke-linecap="round"/>
|
|
5
|
+
<!-- Warning triangle - big, top right -->
|
|
6
|
+
<polygon points="48,0 62,24 34,24" fill="#dc2626"/>
|
|
7
|
+
<line x1="48" y1="7" x2="48" y2="16" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
|
|
8
|
+
<circle cx="48" cy="20" r="1.8" fill="#fff"/>
|
|
9
|
+
</svg>
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>OpenClaw Inspector</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-Br9n3XxB.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DJx37DEV.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-inspector",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Review every conversation your OpenClaw agent has ever had — including deleted sessions — and flag dangerous actions it may have taken without your knowledge.",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"openclaw-inspector": "server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"server.js",
|
|
12
|
+
"danger-rules.json",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "vite",
|
|
18
|
+
"build": "vite build",
|
|
19
|
+
"preview": "vite preview",
|
|
20
|
+
"start": "node server.js",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"test:e2e": "cypress run",
|
|
24
|
+
"test:e2e:open": "cypress open",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Lukavyi/openclaw-inspector.git"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"openclaw",
|
|
33
|
+
"clawdbot",
|
|
34
|
+
"inspector",
|
|
35
|
+
"session-viewer",
|
|
36
|
+
"security",
|
|
37
|
+
"audit",
|
|
38
|
+
"ai-agent",
|
|
39
|
+
"cli"
|
|
40
|
+
],
|
|
41
|
+
"author": "Taras Lukavyi",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"type": "module",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/Lukavyi/openclaw-inspector/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/Lukavyi/openclaw-inspector#readme",
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
53
|
+
"@testing-library/react": "^16.3.2",
|
|
54
|
+
"@types/react": "^19.2.10",
|
|
55
|
+
"@types/react-dom": "^19.2.3",
|
|
56
|
+
"@vitejs/plugin-react": "^5.1.2",
|
|
57
|
+
"cypress": "^15.9.0",
|
|
58
|
+
"jsdom": "^27.4.0",
|
|
59
|
+
"react": "^19.2.4",
|
|
60
|
+
"react-dom": "^19.2.4",
|
|
61
|
+
"typescript": "^5.9.3",
|
|
62
|
+
"vite": "^7.3.1",
|
|
63
|
+
"vitest": "^4.0.18"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"react-virtuoso": "^4.18.1"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Session Viewer Server — serves UI + watches JSONL files via SSE
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, watch, existsSync, mkdirSync, copyFileSync } from "node:fs";
|
|
5
|
+
import { join, extname, resolve } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const PORT = parseInt(process.env.PORT || "9100", 10);
|
|
10
|
+
const SESSIONS_DIR = process.env.SESSIONS_DIR || (() => {
|
|
11
|
+
const openclaw = join(homedir(), ".openclaw", "agents", "main", "sessions");
|
|
12
|
+
const clawdbot = join(homedir(), ".clawdbot", "agents", "main", "sessions");
|
|
13
|
+
if (existsSync(openclaw)) return openclaw;
|
|
14
|
+
if (existsSync(clawdbot)) return clawdbot;
|
|
15
|
+
return openclaw; // default to .openclaw
|
|
16
|
+
})();
|
|
17
|
+
const PROJECT_DIR = new URL(".", import.meta.url).pathname;
|
|
18
|
+
const STATIC_DIR = join(new URL(".", import.meta.url).pathname, "dist");
|
|
19
|
+
const DATA_DIR = process.env.DATA_DIR || join(homedir(), ".openclaw-inspector");
|
|
20
|
+
|
|
21
|
+
// Ensure data dir exists and seed defaults
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(`⚠️ Cannot create data dir ${DATA_DIR}: ${e.message}`);
|
|
26
|
+
console.error(" Set DATA_DIR env to a writable path.");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
// Ensure DATA_DIR is a git repo
|
|
30
|
+
try {
|
|
31
|
+
if (!existsSync(join(DATA_DIR, ".git"))) {
|
|
32
|
+
execSync("git init", { cwd: DATA_DIR, stdio: "ignore" });
|
|
33
|
+
console.log(`🗂️ Initialized git repo in ${DATA_DIR}`);
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`⚠️ Could not init git in ${DATA_DIR}: ${e.message}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function gitCommitProgress(message) {
|
|
40
|
+
try {
|
|
41
|
+
execSync("git add progress.json danger-rules.json", { cwd: DATA_DIR, stdio: "ignore" });
|
|
42
|
+
execSync(`git diff --cached --quiet`, { cwd: DATA_DIR, stdio: "ignore" });
|
|
43
|
+
// If we reach here, nothing staged — skip commit
|
|
44
|
+
} catch {
|
|
45
|
+
// diff --quiet exits 1 when there are staged changes — commit
|
|
46
|
+
try {
|
|
47
|
+
execSync(`git commit -m ${JSON.stringify(message)}`, { cwd: DATA_DIR, stdio: "ignore" });
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultRulesPath = join(PROJECT_DIR, "danger-rules.json");
|
|
53
|
+
const userRulesPath = join(DATA_DIR, "danger-rules.json");
|
|
54
|
+
if (!existsSync(userRulesPath) && existsSync(defaultRulesPath)) {
|
|
55
|
+
copyFileSync(defaultRulesPath, userRulesPath);
|
|
56
|
+
}
|
|
57
|
+
// Initial commit if files exist but not yet tracked
|
|
58
|
+
gitCommitProgress("init: initial state");
|
|
59
|
+
|
|
60
|
+
const CSV_PATH =
|
|
61
|
+
process.env.CSV_PATH || join(new URL(".", import.meta.url).pathname, "sessions_table.csv");
|
|
62
|
+
|
|
63
|
+
// --- SSE clients ---
|
|
64
|
+
const sseClients = new Set();
|
|
65
|
+
|
|
66
|
+
function broadcast(event, data) {
|
|
67
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
68
|
+
for (const res of sseClients) {
|
|
69
|
+
try {
|
|
70
|
+
res.write(msg);
|
|
71
|
+
} catch {
|
|
72
|
+
sseClients.delete(res);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Watch sessions dir ---
|
|
78
|
+
let fsWatcher;
|
|
79
|
+
try {
|
|
80
|
+
fsWatcher = watch(SESSIONS_DIR, { persistent: true }, (eventType, filename) => {
|
|
81
|
+
if (!filename) return;
|
|
82
|
+
if (filename.endsWith(".jsonl") || filename.includes(".deleted.")) {
|
|
83
|
+
broadcast("file-change", { eventType, filename });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
console.log(`👁️ Watching ${SESSIONS_DIR}`);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.error(`Cannot watch ${SESSIONS_DIR}: ${e.message}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- API ---
|
|
92
|
+
function listSessions() {
|
|
93
|
+
const files = readdirSync(SESSIONS_DIR).filter(
|
|
94
|
+
(f) => f.endsWith(".jsonl") || f.includes(".deleted.")
|
|
95
|
+
);
|
|
96
|
+
return files.map((f) => {
|
|
97
|
+
const fullPath = join(SESSIONS_DIR, f);
|
|
98
|
+
const stat = statSync(fullPath);
|
|
99
|
+
// Read first line to get session timestamp and sessionId
|
|
100
|
+
let createdAt = null;
|
|
101
|
+
let sessionId = null;
|
|
102
|
+
try {
|
|
103
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
104
|
+
const firstLine = content.split("\n")[0];
|
|
105
|
+
const obj = JSON.parse(firstLine);
|
|
106
|
+
if (obj.type === "session") {
|
|
107
|
+
if (obj.timestamp) createdAt = obj.timestamp;
|
|
108
|
+
sessionId = obj.sessionId || obj.id || null;
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
return {
|
|
112
|
+
filename: f,
|
|
113
|
+
size: stat.size,
|
|
114
|
+
mtime: stat.mtimeMs,
|
|
115
|
+
createdAt,
|
|
116
|
+
sessionId,
|
|
117
|
+
deleted: f.includes(".deleted."),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readSession(filename) {
|
|
123
|
+
// Sanitize — resolve and verify path stays inside SESSIONS_DIR
|
|
124
|
+
const fullPath = resolve(SESSIONS_DIR, filename);
|
|
125
|
+
if (!fullPath.startsWith(SESSIONS_DIR)) return null;
|
|
126
|
+
if (!existsSync(fullPath)) return null;
|
|
127
|
+
return readFileSync(fullPath, "utf-8");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readCSV() {
|
|
131
|
+
if (!existsSync(CSV_PATH)) return null;
|
|
132
|
+
return readFileSync(CSV_PATH, "utf-8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Session status from sessions.json ---
|
|
136
|
+
function getSessionMeta() {
|
|
137
|
+
const metaPath = join(SESSIONS_DIR, "sessions.json");
|
|
138
|
+
if (!existsSync(metaPath)) return {};
|
|
139
|
+
try {
|
|
140
|
+
const data = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
141
|
+
// Build map: sessionId -> { status, label, key }
|
|
142
|
+
const byId = {};
|
|
143
|
+
for (const [key, val] of Object.entries(data)) {
|
|
144
|
+
const sid = val.sessionId;
|
|
145
|
+
if (sid) {
|
|
146
|
+
byId[sid] = {
|
|
147
|
+
status: "active",
|
|
148
|
+
label: val.label || "",
|
|
149
|
+
key,
|
|
150
|
+
updatedAt: val.updatedAt,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return byId;
|
|
155
|
+
} catch { return {}; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveStatus(filename, metaById) {
|
|
159
|
+
if (filename.includes(".deleted.")) return { status: "deleted", label: "" };
|
|
160
|
+
// Extract sessionId from filename (UUID part before -topic or .jsonl)
|
|
161
|
+
const base = filename.replace(/\.jsonl$/, "").replace(/\.deleted\.\d+$/, "");
|
|
162
|
+
const meta = metaById[base];
|
|
163
|
+
if (meta) return { status: "active", label: meta.label || "" };
|
|
164
|
+
// Try matching by sessionId prefix
|
|
165
|
+
for (const [sid, m] of Object.entries(metaById)) {
|
|
166
|
+
if (base.startsWith(sid) || sid.startsWith(base)) return { status: "active", label: m.label || "" };
|
|
167
|
+
}
|
|
168
|
+
return { status: "orphan", label: "" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- MIME ---
|
|
172
|
+
const MIME = {
|
|
173
|
+
".html": "text/html",
|
|
174
|
+
".js": "application/javascript",
|
|
175
|
+
".css": "text/css",
|
|
176
|
+
".json": "application/json",
|
|
177
|
+
".svg": "image/svg+xml",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// --- Server ---
|
|
181
|
+
const server = createServer((req, res) => {
|
|
182
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
183
|
+
const path = url.pathname;
|
|
184
|
+
|
|
185
|
+
// CORS
|
|
186
|
+
const origin = req.headers.origin || "";
|
|
187
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
|
188
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// SSE endpoint
|
|
192
|
+
if (path === "/api/events") {
|
|
193
|
+
res.writeHead(200, {
|
|
194
|
+
"Content-Type": "text/event-stream",
|
|
195
|
+
"Cache-Control": "no-cache",
|
|
196
|
+
Connection: "keep-alive",
|
|
197
|
+
});
|
|
198
|
+
res.write(`event: connected\ndata: "ok"\n\n`);
|
|
199
|
+
sseClients.add(res);
|
|
200
|
+
req.on("close", () => sseClients.delete(res));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// API: list sessions (now includes status + label)
|
|
205
|
+
if (path === "/api/sessions") {
|
|
206
|
+
const sessions = listSessions();
|
|
207
|
+
const meta = getSessionMeta();
|
|
208
|
+
for (const s of sessions) {
|
|
209
|
+
const info = resolveStatus(s.filename, meta);
|
|
210
|
+
s.status = info.status;
|
|
211
|
+
s.label = info.label;
|
|
212
|
+
}
|
|
213
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
214
|
+
res.end(JSON.stringify(sessions));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// API: session meta (status + labels)
|
|
219
|
+
if (path === "/api/meta") {
|
|
220
|
+
const files = readdirSync(SESSIONS_DIR).filter(
|
|
221
|
+
(f) => f.endsWith(".jsonl") || f.includes(".deleted.")
|
|
222
|
+
);
|
|
223
|
+
const meta = getSessionMeta();
|
|
224
|
+
const result = {};
|
|
225
|
+
for (const f of files) {
|
|
226
|
+
result[f] = resolveStatus(f, meta);
|
|
227
|
+
}
|
|
228
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
229
|
+
res.end(JSON.stringify(result));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// API: message counts for all sessions
|
|
234
|
+
if (path === "/api/counts") {
|
|
235
|
+
const files = readdirSync(SESSIONS_DIR).filter(
|
|
236
|
+
(f) => f.endsWith(".jsonl") || f.includes(".deleted.")
|
|
237
|
+
);
|
|
238
|
+
const counts = {};
|
|
239
|
+
for (const f of files) {
|
|
240
|
+
try {
|
|
241
|
+
const content = readFileSync(join(SESSIONS_DIR, f), "utf-8");
|
|
242
|
+
let total = 0;
|
|
243
|
+
for (const line of content.split("\n")) {
|
|
244
|
+
if (!line.trim()) continue;
|
|
245
|
+
try {
|
|
246
|
+
const obj = JSON.parse(line);
|
|
247
|
+
if (obj.type === "message") total++;
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
counts[f] = total;
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
254
|
+
res.end(JSON.stringify(counts));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// API: danger scan
|
|
259
|
+
if (path === "/api/danger") {
|
|
260
|
+
const rulesPath = existsSync(userRulesPath) ? userRulesPath : join(PROJECT_DIR, "danger-rules.json");
|
|
261
|
+
if (!existsSync(rulesPath)) {
|
|
262
|
+
res.writeHead(404);
|
|
263
|
+
res.end("No rules");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const rules = JSON.parse(readFileSync(rulesPath, "utf-8")).rules;
|
|
267
|
+
const compiled = rules.map((r) => ({
|
|
268
|
+
...r,
|
|
269
|
+
regexes: (r.patterns || []).map((p) => new RegExp(p, "i")),
|
|
270
|
+
toolRules: r.toolRules || null,
|
|
271
|
+
}));
|
|
272
|
+
|
|
273
|
+
const files = readdirSync(SESSIONS_DIR).filter(
|
|
274
|
+
(f) => f.endsWith(".jsonl") || f.includes(".deleted.")
|
|
275
|
+
);
|
|
276
|
+
const results = {};
|
|
277
|
+
for (const f of files) {
|
|
278
|
+
const hits = [];
|
|
279
|
+
try {
|
|
280
|
+
const content = readFileSync(join(SESSIONS_DIR, f), "utf-8");
|
|
281
|
+
for (const line of content.split("\n")) {
|
|
282
|
+
if (!line.trim()) continue;
|
|
283
|
+
let obj;
|
|
284
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
285
|
+
if (obj.type !== "message") continue;
|
|
286
|
+
const msg = obj.message;
|
|
287
|
+
if (!msg || !msg.content || !Array.isArray(msg.content)) continue;
|
|
288
|
+
for (const block of msg.content) {
|
|
289
|
+
// Check ALL string values in toolCall arguments/input recursively
|
|
290
|
+
if (block.type === "toolCall") {
|
|
291
|
+
const src = block.arguments || block.input;
|
|
292
|
+
const toolName = block.name || "";
|
|
293
|
+
const toolAction = src?.action || "";
|
|
294
|
+
|
|
295
|
+
// Tool-based rules (surveillance etc.)
|
|
296
|
+
for (const rule of compiled) {
|
|
297
|
+
if (!rule.toolRules) continue;
|
|
298
|
+
for (const tr of rule.toolRules) {
|
|
299
|
+
if (tr.toolName === toolName && (tr.actions === null || (toolAction && tr.actions.includes(toolAction)))) {
|
|
300
|
+
hits.push({
|
|
301
|
+
msgId: obj.id,
|
|
302
|
+
command: `${toolName}${toolAction ? ": " + toolAction : ""}`,
|
|
303
|
+
category: rule.category,
|
|
304
|
+
severity: rule.severity,
|
|
305
|
+
label: rule.label,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!src) continue;
|
|
312
|
+
// Collect all string values from the object tree
|
|
313
|
+
const strings = [];
|
|
314
|
+
const walk = (v) => {
|
|
315
|
+
if (typeof v === "string") { if (v.length > 2) strings.push(v); }
|
|
316
|
+
else if (Array.isArray(v)) v.forEach(walk);
|
|
317
|
+
else if (v && typeof v === "object") Object.values(v).forEach(walk);
|
|
318
|
+
};
|
|
319
|
+
walk(src);
|
|
320
|
+
if (!strings.length) continue;
|
|
321
|
+
const matched = new Set();
|
|
322
|
+
for (const s of strings) {
|
|
323
|
+
for (const rule of compiled) {
|
|
324
|
+
if (rule.toolRules) continue; // skip tool-based rules here
|
|
325
|
+
if (matched.has(rule.category + s)) continue;
|
|
326
|
+
for (const rx of rule.regexes) {
|
|
327
|
+
if (rx.test(s)) {
|
|
328
|
+
matched.add(rule.category + s);
|
|
329
|
+
hits.push({
|
|
330
|
+
msgId: obj.id,
|
|
331
|
+
command: `${toolName || "?"}: ${s.substring(0, 200)}`,
|
|
332
|
+
category: rule.category,
|
|
333
|
+
severity: rule.severity,
|
|
334
|
+
label: rule.label,
|
|
335
|
+
});
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} catch {}
|
|
345
|
+
if (hits.length > 0) results[f] = hits;
|
|
346
|
+
}
|
|
347
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
348
|
+
res.end(JSON.stringify(results));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// API: read session file
|
|
353
|
+
if (path.startsWith("/api/session/")) {
|
|
354
|
+
const filename = decodeURIComponent(path.slice("/api/session/".length));
|
|
355
|
+
const content = readSession(filename);
|
|
356
|
+
if (content === null) {
|
|
357
|
+
res.writeHead(404);
|
|
358
|
+
res.end("Not found");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
362
|
+
res.end(content);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// API: CSV metadata
|
|
367
|
+
if (path === "/api/csv") {
|
|
368
|
+
const csv = readCSV();
|
|
369
|
+
if (csv === null) {
|
|
370
|
+
res.writeHead(404);
|
|
371
|
+
res.end("No CSV");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
res.writeHead(200, { "Content-Type": "text/csv" });
|
|
375
|
+
res.end(csv);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// API: read progress
|
|
380
|
+
if (path === "/api/progress" && req.method === "GET") {
|
|
381
|
+
const progressPath = join(DATA_DIR, "progress.json");
|
|
382
|
+
if (!existsSync(progressPath)) {
|
|
383
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
384
|
+
res.end("{}");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
388
|
+
res.end(readFileSync(progressPath, "utf-8"));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// API: save progress
|
|
393
|
+
if (path === "/api/progress" && req.method === "POST") {
|
|
394
|
+
const MAX_BODY = 2 * 1024 * 1024; // 2MB
|
|
395
|
+
let body = "";
|
|
396
|
+
let tooBig = false;
|
|
397
|
+
req.on("data", (chunk) => {
|
|
398
|
+
body += chunk;
|
|
399
|
+
if (body.length > MAX_BODY) { tooBig = true; req.destroy(); }
|
|
400
|
+
});
|
|
401
|
+
req.on("end", () => {
|
|
402
|
+
if (tooBig) { res.writeHead(413); res.end("Payload too large"); return; }
|
|
403
|
+
try {
|
|
404
|
+
JSON.parse(body); // validate
|
|
405
|
+
const progressPath = join(DATA_DIR, "progress.json");
|
|
406
|
+
writeFileSync(progressPath, body, "utf-8");
|
|
407
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
408
|
+
res.end('{"ok":true}');
|
|
409
|
+
// Git commit async (don't block response)
|
|
410
|
+
const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
411
|
+
gitCommitProgress(`review: ${now}`);
|
|
412
|
+
} catch {
|
|
413
|
+
res.writeHead(400);
|
|
414
|
+
res.end("Invalid JSON");
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// API: full-text search across all sessions
|
|
421
|
+
if (path === "/api/search") {
|
|
422
|
+
const q = (url.searchParams.get("q") || "").toLowerCase().trim();
|
|
423
|
+
if (!q || q.length < 2) {
|
|
424
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
425
|
+
res.end("[]");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const matches = [];
|
|
429
|
+
try {
|
|
430
|
+
const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith(".jsonl"));
|
|
431
|
+
for (const f of files) {
|
|
432
|
+
try {
|
|
433
|
+
const fullPath = resolve(SESSIONS_DIR, f);
|
|
434
|
+
if (!fullPath.startsWith(SESSIONS_DIR)) continue;
|
|
435
|
+
const content = readFileSync(fullPath, "utf-8").toLowerCase();
|
|
436
|
+
if (content.includes(q)) {
|
|
437
|
+
matches.push(f);
|
|
438
|
+
}
|
|
439
|
+
} catch {}
|
|
440
|
+
}
|
|
441
|
+
} catch {}
|
|
442
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
443
|
+
res.end(JSON.stringify(matches));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Static files — with path traversal protection
|
|
448
|
+
let filePath = path === "/" ? "/index.html" : path;
|
|
449
|
+
const fullPath = resolve(STATIC_DIR, "." + filePath);
|
|
450
|
+
if (!fullPath.startsWith(STATIC_DIR)) {
|
|
451
|
+
res.writeHead(403);
|
|
452
|
+
res.end("Forbidden");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const content = readFileSync(fullPath);
|
|
457
|
+
const ext = extname(filePath);
|
|
458
|
+
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
|
459
|
+
res.end(content);
|
|
460
|
+
} catch {
|
|
461
|
+
res.writeHead(404);
|
|
462
|
+
res.end("Not found");
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
467
|
+
server.listen(PORT, HOST, () => {
|
|
468
|
+
console.log(`🖥️ Session Viewer: http://localhost:${PORT}`);
|
|
469
|
+
console.log(`📂 Sessions: ${SESSIONS_DIR}`);
|
|
470
|
+
console.log(`📊 CSV: ${CSV_PATH}`);
|
|
471
|
+
console.log(`💾 Data: ${DATA_DIR}`);
|
|
472
|
+
});
|