@xiaolei.shawn/mcp-server 0.2.0
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 +159 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +63 -0
- package/dist/dashboard.d.ts +4 -0
- package/dist/dashboard.js +401 -0
- package/dist/event-envelope.d.ts +33 -0
- package/dist/event-envelope.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +147 -0
- package/dist/store.d.ts +70 -0
- package/dist/store.js +254 -0
- package/dist/tools.d.ts +661 -0
- package/dist/tools.js +783 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AL Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# @xiaolei.shawn/mcp-server
|
|
2
|
+
|
|
3
|
+
Local-first MCP server for AI agent session auditing.
|
|
4
|
+
|
|
5
|
+
- Records canonical session events via MCP tools
|
|
6
|
+
- Persists events as local JSONL files
|
|
7
|
+
- Serves a local web dashboard + API from the same process
|
|
8
|
+
- Data never leaves the machine unless you explicitly move files
|
|
9
|
+
|
|
10
|
+
## Open-source connector model
|
|
11
|
+
|
|
12
|
+
This package is intended to be the open-source MCP connector layer.
|
|
13
|
+
|
|
14
|
+
- Open source: MCP tools + canonical event capture + local storage/API serving
|
|
15
|
+
- Proprietary (optional): advanced analyzer dashboard/heuristics binaries can be served separately
|
|
16
|
+
|
|
17
|
+
You can point the built-in dashboard server to any static bundle via `AL_DASHBOARD_WEBAPP_DIR`.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- Canonical event capture with sequence ordering and timestamps
|
|
22
|
+
- Gateway tools for low-friction agent instrumentation
|
|
23
|
+
- Local dashboard server (`/api/sessions`, `/api/sessions/:key`)
|
|
24
|
+
- Session storage on local disk (`AL_SESSIONS_DIR`)
|
|
25
|
+
- Local gateway API for middleware (`/api/gateway/*`)
|
|
26
|
+
- Export session JSON with normalized snapshot (`agentlens export`)
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @xiaolei.shawn/mcp-server
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
agentlens start --open
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This starts the local dashboard + gateway API on `http://127.0.0.1:4317` and opens a browser tab.
|
|
41
|
+
|
|
42
|
+
MCP mode (for Cursor/Codex MCP config):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
agentlens mcp
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## MCP Tools
|
|
49
|
+
|
|
50
|
+
### Canonical recorders
|
|
51
|
+
|
|
52
|
+
- `record_session_start`
|
|
53
|
+
- `record_intent`
|
|
54
|
+
- `record_activity`
|
|
55
|
+
- `record_decision`
|
|
56
|
+
- `record_assumption`
|
|
57
|
+
- `record_verification`
|
|
58
|
+
- `record_session_end`
|
|
59
|
+
|
|
60
|
+
### Gateway tools
|
|
61
|
+
|
|
62
|
+
- `gateway_begin_run`
|
|
63
|
+
- `gateway_act`
|
|
64
|
+
- `gateway_end_run`
|
|
65
|
+
|
|
66
|
+
## Local Dashboard
|
|
67
|
+
|
|
68
|
+
When the server starts, it also runs a local HTTP server (enabled by default).
|
|
69
|
+
|
|
70
|
+
Default URL:
|
|
71
|
+
|
|
72
|
+
- `http://127.0.0.1:4317`
|
|
73
|
+
|
|
74
|
+
API endpoints:
|
|
75
|
+
|
|
76
|
+
- `GET /api/health`
|
|
77
|
+
- `GET /api/sessions`
|
|
78
|
+
- `GET /api/sessions/:key`
|
|
79
|
+
- `GET /api/sessions/:key/export`
|
|
80
|
+
- `POST /api/gateway/begin`
|
|
81
|
+
- `POST /api/gateway/act`
|
|
82
|
+
- `POST /api/gateway/end`
|
|
83
|
+
|
|
84
|
+
If web assets are available (default `../webapp/dist`), they are served by the same server.
|
|
85
|
+
|
|
86
|
+
## Automatic instrumentation defaults
|
|
87
|
+
|
|
88
|
+
To reduce agent friction:
|
|
89
|
+
|
|
90
|
+
- `gateway_act` auto-creates a session if no active session exists.
|
|
91
|
+
- `gateway_act` auto-creates an intent when activity arrives without an active intent.
|
|
92
|
+
- `record_session_end` and `gateway_end_run` persist both raw JSONL and a normalized session snapshot.
|
|
93
|
+
|
|
94
|
+
## Environment Variables
|
|
95
|
+
|
|
96
|
+
- `AL_SESSIONS_DIR` (default: `./sessions`): local session file directory.
|
|
97
|
+
- `AL_DASHBOARD_ENABLED` (default: `true`): enable/disable dashboard server.
|
|
98
|
+
- `AL_DASHBOARD_HOST` (default: `127.0.0.1`): dashboard bind host.
|
|
99
|
+
- `AL_DASHBOARD_PORT` (default: `4317`): dashboard bind port.
|
|
100
|
+
- `AL_DASHBOARD_WEBAPP_DIR` (default: auto): static webapp build directory.
|
|
101
|
+
- `AL_WORKSPACE_ROOT` (default: `process.cwd()`): workspace root for safe path operations.
|
|
102
|
+
- `AL_AUTO_GOAL` (default: `Agent task execution`): fallback goal for auto-started sessions.
|
|
103
|
+
- `AL_AUTO_USER_PROMPT` (default: `Auto-instrumented run`): fallback prompt for auto-started sessions.
|
|
104
|
+
|
|
105
|
+
## Cursor/Codex MCP configuration example
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"mcpServers": {
|
|
110
|
+
"agentlens": {
|
|
111
|
+
"command": "agentlens",
|
|
112
|
+
"args": ["mcp"],
|
|
113
|
+
"env": {
|
|
114
|
+
"AL_SESSIONS_DIR": "/absolute/path/to/sessions"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Build from source
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pnpm install
|
|
125
|
+
pnpm --filter @xiaolei.shawn/mcp-server build
|
|
126
|
+
pnpm --filter @xiaolei.shawn/mcp-server start
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Export session JSON
|
|
130
|
+
|
|
131
|
+
Export latest session:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
agentlens export --latest --out ./latest.session.json
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Export by session id:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
agentlens export --session sess_1771256059058_2bd2bd8f --out ./session.json
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Publish checklist
|
|
144
|
+
|
|
145
|
+
1. Update version in `package.json`.
|
|
146
|
+
2. Confirm repository URLs in `package.json` are correct.
|
|
147
|
+
3. Run:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npm run build
|
|
151
|
+
npm pack --dry-run
|
|
152
|
+
npm publish --access public --dry-run
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
4. Publish:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm publish --access public
|
|
159
|
+
```
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server config from environment.
|
|
3
|
+
*/
|
|
4
|
+
export declare function getSessionsDir(): string;
|
|
5
|
+
export declare function isDashboardEnabled(): boolean;
|
|
6
|
+
export declare function getDashboardHost(): string;
|
|
7
|
+
export declare function getDashboardPort(): number;
|
|
8
|
+
export declare function getDashboardWebappDir(): string;
|
|
9
|
+
export declare function isWatcherEnabled(): boolean;
|
|
10
|
+
export declare function getWatcherDir(): string;
|
|
11
|
+
/** Workspace root for file_op: all paths are resolved and validated against this. */
|
|
12
|
+
export declare function getWorkspaceRoot(): string;
|
|
13
|
+
/** Resolve path relative to workspace; throw if it escapes workspace (path traversal). */
|
|
14
|
+
export declare function resolveWithinWorkspace(workspaceRoot: string, rawPath: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server config from environment.
|
|
3
|
+
*/
|
|
4
|
+
import { dirname, resolve, sep } from "node:path";
|
|
5
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
export function getSessionsDir() {
|
|
8
|
+
return process.env.AL_SESSIONS_DIR ?? process.env.MCP_AL_SESSIONS_DIR ?? "./sessions";
|
|
9
|
+
}
|
|
10
|
+
export function isDashboardEnabled() {
|
|
11
|
+
const raw = process.env.AL_DASHBOARD_ENABLED ?? process.env.MCP_AL_DASHBOARD_ENABLED;
|
|
12
|
+
if (raw === undefined)
|
|
13
|
+
return true;
|
|
14
|
+
return raw === "1" || raw === "true";
|
|
15
|
+
}
|
|
16
|
+
export function getDashboardHost() {
|
|
17
|
+
return process.env.AL_DASHBOARD_HOST ?? process.env.MCP_AL_DASHBOARD_HOST ?? "127.0.0.1";
|
|
18
|
+
}
|
|
19
|
+
export function getDashboardPort() {
|
|
20
|
+
const raw = process.env.AL_DASHBOARD_PORT ?? process.env.MCP_AL_DASHBOARD_PORT;
|
|
21
|
+
if (!raw)
|
|
22
|
+
return 4317;
|
|
23
|
+
const parsed = Number(raw);
|
|
24
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
25
|
+
throw new Error(`Invalid AL_DASHBOARD_PORT: ${raw}`);
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
export function getDashboardWebappDir() {
|
|
30
|
+
const explicit = process.env.AL_DASHBOARD_WEBAPP_DIR ?? process.env.MCP_AL_DASHBOARD_WEBAPP_DIR;
|
|
31
|
+
if (explicit)
|
|
32
|
+
return resolve(explicit);
|
|
33
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
// dist/config.js -> ../.. lands in mcp-server root
|
|
35
|
+
const serverRoot = resolve(moduleDir, "..");
|
|
36
|
+
return resolve(serverRoot, "../webapp/dist");
|
|
37
|
+
}
|
|
38
|
+
export function isWatcherEnabled() {
|
|
39
|
+
return process.env.AL_WATCHER_ENABLED === "1" || process.env.AL_WATCHER_ENABLED === "true";
|
|
40
|
+
}
|
|
41
|
+
export function getWatcherDir() {
|
|
42
|
+
return process.env.AL_WATCHER_DIR ?? process.env.MCP_AL_WATCHER_DIR ?? "./watcher-events";
|
|
43
|
+
}
|
|
44
|
+
/** Workspace root for file_op: all paths are resolved and validated against this. */
|
|
45
|
+
export function getWorkspaceRoot() {
|
|
46
|
+
const root = process.env.AL_WORKSPACE_ROOT ?? process.env.MCP_AL_WORKSPACE_ROOT ?? process.cwd();
|
|
47
|
+
try {
|
|
48
|
+
return realpathSync(resolve(root));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return resolve(root);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Resolve path relative to workspace; throw if it escapes workspace (path traversal). */
|
|
55
|
+
export function resolveWithinWorkspace(workspaceRoot, rawPath) {
|
|
56
|
+
const normalized = resolve(workspaceRoot, rawPath);
|
|
57
|
+
const real = existsSync(normalized) ? realpathSync(normalized) : resolve(normalized);
|
|
58
|
+
const prefix = workspaceRoot.endsWith(sep) ? workspaceRoot : workspaceRoot + sep;
|
|
59
|
+
if (real !== workspaceRoot && !real.startsWith(prefix)) {
|
|
60
|
+
throw new Error(`Path escapes workspace: ${rawPath}`);
|
|
61
|
+
}
|
|
62
|
+
return real;
|
|
63
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join, normalize, resolve } from "node:path";
|
|
4
|
+
import { getDashboardHost, getDashboardPort, getDashboardWebappDir, getSessionsDir, isDashboardEnabled, } from "./config.js";
|
|
5
|
+
import { exportSessionJson } from "./store.js";
|
|
6
|
+
import { handleGatewayAct, handleGatewayBeginRun, handleGatewayEndRun } from "./tools.js";
|
|
7
|
+
function json(res, status, payload) {
|
|
8
|
+
res.writeHead(status, {
|
|
9
|
+
"content-type": "application/json; charset=utf-8",
|
|
10
|
+
"cache-control": "no-store",
|
|
11
|
+
});
|
|
12
|
+
res.end(JSON.stringify(payload));
|
|
13
|
+
}
|
|
14
|
+
async function readJsonBody(req) {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
for await (const chunk of req) {
|
|
17
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
18
|
+
}
|
|
19
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
20
|
+
if (!raw)
|
|
21
|
+
return {};
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error("Invalid JSON body.");
|
|
28
|
+
}
|
|
29
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
30
|
+
throw new Error("Request body must be a JSON object.");
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
function parseToolResult(result) {
|
|
35
|
+
if (!result || typeof result !== "object") {
|
|
36
|
+
return { ok: false, payload: null, error: "Invalid tool response." };
|
|
37
|
+
}
|
|
38
|
+
const tool = result;
|
|
39
|
+
const text = tool.content?.[0]?.text;
|
|
40
|
+
if (tool.isError) {
|
|
41
|
+
return { ok: false, payload: null, error: text ?? "Tool failed." };
|
|
42
|
+
}
|
|
43
|
+
if (!text)
|
|
44
|
+
return { ok: true, payload: {} };
|
|
45
|
+
try {
|
|
46
|
+
return { ok: true, payload: JSON.parse(text) };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { ok: true, payload: { message: text } };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isCanonicalEvent(raw) {
|
|
53
|
+
if (!raw || typeof raw !== "object")
|
|
54
|
+
return false;
|
|
55
|
+
const event = raw;
|
|
56
|
+
return (typeof event.id === "string" &&
|
|
57
|
+
typeof event.session_id === "string" &&
|
|
58
|
+
typeof event.seq === "number" &&
|
|
59
|
+
typeof event.ts === "string" &&
|
|
60
|
+
typeof event.kind === "string" &&
|
|
61
|
+
!!event.actor &&
|
|
62
|
+
typeof event.actor.type === "string" &&
|
|
63
|
+
!!event.payload &&
|
|
64
|
+
typeof event.payload === "object" &&
|
|
65
|
+
typeof event.schema_version === "number");
|
|
66
|
+
}
|
|
67
|
+
function parseSessionContent(content) {
|
|
68
|
+
const text = content.trim();
|
|
69
|
+
if (!text)
|
|
70
|
+
throw new Error("Empty session file.");
|
|
71
|
+
if (text.startsWith("{") || text.startsWith("[")) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(text);
|
|
74
|
+
if (Array.isArray(parsed)) {
|
|
75
|
+
const events = parsed.filter(isCanonicalEvent);
|
|
76
|
+
if (events.length !== parsed.length)
|
|
77
|
+
throw new Error("Invalid event in JSON array.");
|
|
78
|
+
return toSessionPayload(events);
|
|
79
|
+
}
|
|
80
|
+
if (parsed && typeof parsed === "object") {
|
|
81
|
+
const obj = parsed;
|
|
82
|
+
if (Array.isArray(obj.events) && obj.events.every(isCanonicalEvent)) {
|
|
83
|
+
return {
|
|
84
|
+
session_id: obj.session_id ?? obj.events[0]?.session_id ?? "unknown",
|
|
85
|
+
goal: typeof obj.goal === "string" ? obj.goal : undefined,
|
|
86
|
+
user_prompt: typeof obj.user_prompt === "string" ? obj.user_prompt : undefined,
|
|
87
|
+
started_at: typeof obj.started_at === "string" ? obj.started_at : undefined,
|
|
88
|
+
ended_at: typeof obj.ended_at === "string" ? obj.ended_at : undefined,
|
|
89
|
+
events: [...obj.events].sort((a, b) => (a.seq === b.seq ? a.ts.localeCompare(b.ts) : a.seq - b.seq)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Fall back to JSONL parsing below.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const lines = text
|
|
99
|
+
.split("\n")
|
|
100
|
+
.map((line) => line.trim())
|
|
101
|
+
.filter((line) => line.length > 0);
|
|
102
|
+
const events = lines.map((line, idx) => {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(line);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error(`Invalid JSONL line ${idx + 1}`);
|
|
109
|
+
}
|
|
110
|
+
if (!isCanonicalEvent(parsed)) {
|
|
111
|
+
throw new Error(`Invalid canonical event at JSONL line ${idx + 1}`);
|
|
112
|
+
}
|
|
113
|
+
return parsed;
|
|
114
|
+
});
|
|
115
|
+
return toSessionPayload(events);
|
|
116
|
+
}
|
|
117
|
+
function toSessionPayload(events) {
|
|
118
|
+
const sorted = [...events].sort((a, b) => (a.seq === b.seq ? a.ts.localeCompare(b.ts) : a.seq - b.seq));
|
|
119
|
+
const start = sorted.find((event) => event.kind === "session_start");
|
|
120
|
+
const end = [...sorted].reverse().find((event) => event.kind === "session_end");
|
|
121
|
+
const startPayload = (start?.payload ?? {});
|
|
122
|
+
return {
|
|
123
|
+
session_id: sorted[0]?.session_id ?? "unknown",
|
|
124
|
+
goal: typeof startPayload.goal === "string" ? startPayload.goal : undefined,
|
|
125
|
+
user_prompt: typeof startPayload.user_prompt === "string" ? startPayload.user_prompt : undefined,
|
|
126
|
+
started_at: start?.ts ?? sorted[0]?.ts,
|
|
127
|
+
ended_at: end?.ts,
|
|
128
|
+
events: sorted,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function readSessionFile(absolutePath) {
|
|
132
|
+
const raw = readFileSync(absolutePath, "utf-8");
|
|
133
|
+
return parseSessionContent(raw);
|
|
134
|
+
}
|
|
135
|
+
function deriveOutcome(events) {
|
|
136
|
+
const end = [...events].reverse().find((event) => event.kind === "session_end");
|
|
137
|
+
const outcome = end?.payload?.outcome;
|
|
138
|
+
return outcome === "completed" || outcome === "partial" || outcome === "failed" || outcome === "aborted"
|
|
139
|
+
? outcome
|
|
140
|
+
: "unknown";
|
|
141
|
+
}
|
|
142
|
+
function listSessionFiles() {
|
|
143
|
+
const sessionsDir = resolve(getSessionsDir());
|
|
144
|
+
if (!existsSync(sessionsDir))
|
|
145
|
+
return [];
|
|
146
|
+
const files = readdirSync(sessionsDir).filter((file) => file.endsWith(".jsonl") || file.endsWith(".json"));
|
|
147
|
+
const summaries = [];
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
const absolutePath = join(sessionsDir, file);
|
|
150
|
+
const stats = statSync(absolutePath);
|
|
151
|
+
if (!stats.isFile())
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
const payload = readSessionFile(absolutePath);
|
|
155
|
+
summaries.push({
|
|
156
|
+
key: file,
|
|
157
|
+
file,
|
|
158
|
+
absolute_path: absolutePath,
|
|
159
|
+
session_id: payload.session_id,
|
|
160
|
+
started_at: payload.started_at,
|
|
161
|
+
ended_at: payload.ended_at,
|
|
162
|
+
goal: payload.goal,
|
|
163
|
+
outcome: deriveOutcome(payload.events),
|
|
164
|
+
event_count: payload.events.length,
|
|
165
|
+
size_bytes: stats.size,
|
|
166
|
+
updated_at: stats.mtime.toISOString(),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Skip malformed files from API listing to keep dashboard stable.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
summaries.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
174
|
+
return summaries;
|
|
175
|
+
}
|
|
176
|
+
function contentType(pathname) {
|
|
177
|
+
const ext = extname(pathname).toLowerCase();
|
|
178
|
+
if (ext === ".html")
|
|
179
|
+
return "text/html; charset=utf-8";
|
|
180
|
+
if (ext === ".css")
|
|
181
|
+
return "text/css; charset=utf-8";
|
|
182
|
+
if (ext === ".js")
|
|
183
|
+
return "application/javascript; charset=utf-8";
|
|
184
|
+
if (ext === ".json")
|
|
185
|
+
return "application/json; charset=utf-8";
|
|
186
|
+
if (ext === ".svg")
|
|
187
|
+
return "image/svg+xml";
|
|
188
|
+
if (ext === ".png")
|
|
189
|
+
return "image/png";
|
|
190
|
+
if (ext === ".jpg" || ext === ".jpeg")
|
|
191
|
+
return "image/jpeg";
|
|
192
|
+
if (ext === ".ico")
|
|
193
|
+
return "image/x-icon";
|
|
194
|
+
return "application/octet-stream";
|
|
195
|
+
}
|
|
196
|
+
function serveMissingWebapp(res) {
|
|
197
|
+
res.writeHead(503, { "content-type": "text/html; charset=utf-8" });
|
|
198
|
+
res.end([
|
|
199
|
+
"<!doctype html>",
|
|
200
|
+
"<html><body style='font-family:sans-serif;background:#020617;color:#e2e8f0;padding:24px'>",
|
|
201
|
+
"<h1>AL Dashboard Not Built</h1>",
|
|
202
|
+
"<p>Build the web app first:</p>",
|
|
203
|
+
"<pre>cd /path/to/AL/webapp && npm run build</pre>",
|
|
204
|
+
"</body></html>",
|
|
205
|
+
].join(""));
|
|
206
|
+
}
|
|
207
|
+
function safeJoin(root, requestPath) {
|
|
208
|
+
const normalizedPath = normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
209
|
+
const joined = resolve(root, `.${normalizedPath}`);
|
|
210
|
+
if (joined !== root && !joined.startsWith(`${root}/`))
|
|
211
|
+
return null;
|
|
212
|
+
return joined;
|
|
213
|
+
}
|
|
214
|
+
async function handleApi(req, res, pathname) {
|
|
215
|
+
if (!pathname.startsWith("/api/"))
|
|
216
|
+
return false;
|
|
217
|
+
if (pathname === "/api/health") {
|
|
218
|
+
if (req.method !== "GET") {
|
|
219
|
+
json(res, 405, { error: "Method not allowed" });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
json(res, 200, {
|
|
223
|
+
ok: true,
|
|
224
|
+
local_only: true,
|
|
225
|
+
sessions_dir: resolve(getSessionsDir()),
|
|
226
|
+
ts: new Date().toISOString(),
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (pathname === "/api/sessions") {
|
|
231
|
+
if (req.method !== "GET") {
|
|
232
|
+
json(res, 405, { error: "Method not allowed" });
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
json(res, 200, { sessions: listSessionFiles() });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (pathname === "/api/gateway/begin") {
|
|
239
|
+
if (req.method !== "POST") {
|
|
240
|
+
json(res, 405, { error: "Method not allowed" });
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const body = await readJsonBody(req);
|
|
245
|
+
const parsed = parseToolResult(await handleGatewayBeginRun(body));
|
|
246
|
+
if (!parsed.ok) {
|
|
247
|
+
json(res, 400, { error: parsed.error });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
json(res, 200, parsed.payload);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (pathname === "/api/gateway/act") {
|
|
258
|
+
if (req.method !== "POST") {
|
|
259
|
+
json(res, 405, { error: "Method not allowed" });
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const body = await readJsonBody(req);
|
|
264
|
+
const parsed = parseToolResult(await handleGatewayAct(body));
|
|
265
|
+
if (!parsed.ok) {
|
|
266
|
+
json(res, 400, { error: parsed.error });
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
json(res, 200, parsed.payload);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (pathname === "/api/gateway/end") {
|
|
277
|
+
if (req.method !== "POST") {
|
|
278
|
+
json(res, 405, { error: "Method not allowed" });
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const body = await readJsonBody(req);
|
|
283
|
+
const parsed = parseToolResult(await handleGatewayEndRun(body));
|
|
284
|
+
if (!parsed.ok) {
|
|
285
|
+
json(res, 400, { error: parsed.error });
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
json(res, 200, parsed.payload);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
if (pathname.startsWith("/api/sessions/")) {
|
|
296
|
+
if (req.method !== "GET") {
|
|
297
|
+
json(res, 405, { error: "Method not allowed" });
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
const key = decodeURIComponent(pathname.slice("/api/sessions/".length));
|
|
301
|
+
if (!key) {
|
|
302
|
+
json(res, 400, { error: "Missing session key." });
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (key.endsWith("/export")) {
|
|
306
|
+
const rawKey = key.slice(0, -"/export".length);
|
|
307
|
+
const summary = listSessionFiles().find((item) => item.key === rawKey || item.session_id === rawKey);
|
|
308
|
+
if (!summary) {
|
|
309
|
+
json(res, 404, { error: "Session not found." });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const exported = exportSessionJson(summary.session_id);
|
|
314
|
+
res.writeHead(200, {
|
|
315
|
+
"content-type": "application/json; charset=utf-8",
|
|
316
|
+
"cache-control": "no-store",
|
|
317
|
+
"content-disposition": `attachment; filename="${summary.session_id}.session.json"`,
|
|
318
|
+
});
|
|
319
|
+
res.end(exported);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
json(res, 500, {
|
|
323
|
+
error: error instanceof Error ? error.message : "Failed to export session.",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
const summary = listSessionFiles().find((item) => item.key === key || item.session_id === key);
|
|
329
|
+
if (!summary) {
|
|
330
|
+
json(res, 404, { error: "Session not found." });
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const payload = readSessionFile(summary.absolute_path);
|
|
335
|
+
json(res, 200, payload);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
json(res, 500, {
|
|
339
|
+
error: error instanceof Error ? error.message : "Failed to read session file.",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
json(res, 404, { error: "Unknown API endpoint." });
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
function handleStatic(req, res) {
|
|
348
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
349
|
+
const pathname = url.pathname;
|
|
350
|
+
const webappDir = getDashboardWebappDir();
|
|
351
|
+
handleApi(req, res, pathname)
|
|
352
|
+
.then((handled) => {
|
|
353
|
+
if (handled)
|
|
354
|
+
return;
|
|
355
|
+
if (!existsSync(webappDir)) {
|
|
356
|
+
serveMissingWebapp(res);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const relative = pathname === "/" ? "/index.html" : pathname;
|
|
360
|
+
const resolved = safeJoin(webappDir, relative);
|
|
361
|
+
if (!resolved) {
|
|
362
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
363
|
+
res.end("Bad request");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const hasExt = extname(relative).length > 0;
|
|
367
|
+
const target = existsSync(resolved) ? resolved : hasExt ? null : join(webappDir, "index.html");
|
|
368
|
+
if (!target || !existsSync(target)) {
|
|
369
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
370
|
+
res.end("Not found");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const body = readFileSync(target);
|
|
374
|
+
res.writeHead(200, {
|
|
375
|
+
"content-type": contentType(target),
|
|
376
|
+
"cache-control": target.endsWith("index.html") ? "no-cache" : "public, max-age=3600",
|
|
377
|
+
});
|
|
378
|
+
res.end(body);
|
|
379
|
+
})
|
|
380
|
+
.catch((error) => {
|
|
381
|
+
json(res, 500, {
|
|
382
|
+
error: error instanceof Error ? error.message : "Unhandled server error.",
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
export function startDashboardServer() {
|
|
387
|
+
if (!isDashboardEnabled()) {
|
|
388
|
+
process.stderr.write("AL dashboard disabled (AL_DASHBOARD_ENABLED=false)\n");
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const host = getDashboardHost();
|
|
392
|
+
const port = getDashboardPort();
|
|
393
|
+
const server = createServer((req, res) => handleStatic(req, res));
|
|
394
|
+
server.listen(port, host, () => {
|
|
395
|
+
process.stderr.write(`AL dashboard listening at http://${host}:${port} (sessions: ${resolve(getSessionsDir())}, webapp: ${getDashboardWebappDir()})\n`);
|
|
396
|
+
});
|
|
397
|
+
server.on("error", (error) => {
|
|
398
|
+
process.stderr.write(`AL dashboard failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
399
|
+
});
|
|
400
|
+
return { host, port };
|
|
401
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type ActorType = "agent" | "user" | "system" | "tool";
|
|
2
|
+
export interface CanonicalEvent {
|
|
3
|
+
id: string;
|
|
4
|
+
session_id: string;
|
|
5
|
+
seq: number;
|
|
6
|
+
ts: string;
|
|
7
|
+
kind: string;
|
|
8
|
+
actor: {
|
|
9
|
+
type: ActorType;
|
|
10
|
+
id?: string;
|
|
11
|
+
};
|
|
12
|
+
scope?: {
|
|
13
|
+
intent_id?: string;
|
|
14
|
+
file?: string;
|
|
15
|
+
module?: string;
|
|
16
|
+
};
|
|
17
|
+
payload: Record<string, unknown>;
|
|
18
|
+
derived?: boolean;
|
|
19
|
+
confidence?: number;
|
|
20
|
+
visibility?: "raw" | "review" | "debug";
|
|
21
|
+
schema_version: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const EVENT_SCHEMA_VERSION = 1;
|
|
24
|
+
export interface SessionLogFile {
|
|
25
|
+
session_id: string;
|
|
26
|
+
goal: string;
|
|
27
|
+
user_prompt?: string;
|
|
28
|
+
repo?: string;
|
|
29
|
+
branch?: string;
|
|
30
|
+
started_at: string;
|
|
31
|
+
ended_at?: string;
|
|
32
|
+
events: CanonicalEvent[];
|
|
33
|
+
}
|