heron-ai 0.1.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 +423 -0
- package/dist/bin/heron.d.ts +3 -0
- package/dist/bin/heron.d.ts.map +1 -0
- package/dist/bin/heron.js +198 -0
- package/dist/bin/heron.js.map +1 -0
- package/dist/src/analysis/analyzer.d.ts +14 -0
- package/dist/src/analysis/analyzer.d.ts.map +1 -0
- package/dist/src/analysis/analyzer.js +130 -0
- package/dist/src/analysis/analyzer.js.map +1 -0
- package/dist/src/analysis/risk-scorer.d.ts +20 -0
- package/dist/src/analysis/risk-scorer.d.ts.map +1 -0
- package/dist/src/analysis/risk-scorer.js +143 -0
- package/dist/src/analysis/risk-scorer.js.map +1 -0
- package/dist/src/config/loader.d.ts +15 -0
- package/dist/src/config/loader.d.ts.map +1 -0
- package/dist/src/config/loader.js +39 -0
- package/dist/src/config/loader.js.map +1 -0
- package/dist/src/config/schema.d.ts +146 -0
- package/dist/src/config/schema.d.ts.map +1 -0
- package/dist/src/config/schema.js +27 -0
- package/dist/src/config/schema.js.map +1 -0
- package/dist/src/connectors/http-connector.d.ts +17 -0
- package/dist/src/connectors/http-connector.d.ts.map +1 -0
- package/dist/src/connectors/http-connector.js +56 -0
- package/dist/src/connectors/http-connector.js.map +1 -0
- package/dist/src/connectors/index.d.ts +5 -0
- package/dist/src/connectors/index.d.ts.map +1 -0
- package/dist/src/connectors/index.js +13 -0
- package/dist/src/connectors/index.js.map +1 -0
- package/dist/src/connectors/interactive-connector.d.ts +13 -0
- package/dist/src/connectors/interactive-connector.d.ts.map +1 -0
- package/dist/src/connectors/interactive-connector.js +44 -0
- package/dist/src/connectors/interactive-connector.js.map +1 -0
- package/dist/src/connectors/types.d.ts +15 -0
- package/dist/src/connectors/types.d.ts.map +1 -0
- package/dist/src/connectors/types.js +2 -0
- package/dist/src/connectors/types.js.map +1 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +60 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/interview/interviewer.d.ts +19 -0
- package/dist/src/interview/interviewer.d.ts.map +1 -0
- package/dist/src/interview/interviewer.js +68 -0
- package/dist/src/interview/interviewer.js.map +1 -0
- package/dist/src/interview/protocol.d.ts +38 -0
- package/dist/src/interview/protocol.d.ts.map +1 -0
- package/dist/src/interview/protocol.js +290 -0
- package/dist/src/interview/protocol.js.map +1 -0
- package/dist/src/interview/questions.d.ts +20 -0
- package/dist/src/interview/questions.d.ts.map +1 -0
- package/dist/src/interview/questions.js +131 -0
- package/dist/src/interview/questions.js.map +1 -0
- package/dist/src/llm/client.d.ts +13 -0
- package/dist/src/llm/client.d.ts.map +1 -0
- package/dist/src/llm/client.js +128 -0
- package/dist/src/llm/client.js.map +1 -0
- package/dist/src/llm/prompts.d.ts +13 -0
- package/dist/src/llm/prompts.d.ts.map +1 -0
- package/dist/src/llm/prompts.js +192 -0
- package/dist/src/llm/prompts.js.map +1 -0
- package/dist/src/report/generator.d.ts +23 -0
- package/dist/src/report/generator.d.ts.map +1 -0
- package/dist/src/report/generator.js +304 -0
- package/dist/src/report/generator.js.map +1 -0
- package/dist/src/report/templates.d.ts +3 -0
- package/dist/src/report/templates.d.ts.map +1 -0
- package/dist/src/report/templates.js +386 -0
- package/dist/src/report/templates.js.map +1 -0
- package/dist/src/report/types.d.ts +954 -0
- package/dist/src/report/types.d.ts.map +1 -0
- package/dist/src/report/types.js +161 -0
- package/dist/src/report/types.js.map +1 -0
- package/dist/src/server/index.d.ts +17 -0
- package/dist/src/server/index.d.ts.map +1 -0
- package/dist/src/server/index.js +650 -0
- package/dist/src/server/index.js.map +1 -0
- package/dist/src/server/sessions.d.ts +68 -0
- package/dist/src/server/sessions.d.ts.map +1 -0
- package/dist/src/server/sessions.js +268 -0
- package/dist/src/server/sessions.js.map +1 -0
- package/dist/src/util/id.d.ts +2 -0
- package/dist/src/util/id.d.ts.map +1 -0
- package/dist/src/util/id.js +5 -0
- package/dist/src/util/id.js.map +1 -0
- package/dist/src/util/logger.d.ts +9 -0
- package/dist/src/util/logger.d.ts.map +1 -0
- package/dist/src/util/logger.js +32 -0
- package/dist/src/util/logger.js.map +1 -0
- package/heron.example.yaml +46 -0
- package/package.json +40 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { SessionManager } from './sessions.js';
|
|
3
|
+
import { createLLMClient } from '../llm/client.js';
|
|
4
|
+
import * as logger from '../util/logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Starts the Heron server.
|
|
7
|
+
*
|
|
8
|
+
* Exposes two API surfaces:
|
|
9
|
+
* 1. /v1/chat/completions — OpenAI-compatible (agents connect as if talking to an LLM)
|
|
10
|
+
* 2. /api/sessions — Simple REST API for managing interrogation sessions
|
|
11
|
+
*/
|
|
12
|
+
export async function startServer(config) {
|
|
13
|
+
const llmClient = await createLLMClient(config.llm);
|
|
14
|
+
const sessions = new SessionManager(llmClient, {
|
|
15
|
+
maxFollowUps: config.maxFollowUps,
|
|
16
|
+
reportDir: config.reportDir,
|
|
17
|
+
});
|
|
18
|
+
const server = createServer(async (req, res) => {
|
|
19
|
+
// CORS
|
|
20
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
21
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
22
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Session-Id');
|
|
23
|
+
if (req.method === 'OPTIONS') {
|
|
24
|
+
res.writeHead(204);
|
|
25
|
+
res.end();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
30
|
+
// OpenAI-compatible endpoint
|
|
31
|
+
if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
|
|
32
|
+
await handleChatCompletions(req, res, sessions);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// REST: list sessions
|
|
36
|
+
if (url.pathname === '/api/sessions' && req.method === 'GET') {
|
|
37
|
+
await handleListSessions(res, sessions);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// REST: get session / report
|
|
41
|
+
const sessionMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)$/);
|
|
42
|
+
if (sessionMatch && req.method === 'GET') {
|
|
43
|
+
await handleGetSession(res, sessions, sessionMatch[1]);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const reportMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/report$/);
|
|
47
|
+
if (reportMatch && req.method === 'GET') {
|
|
48
|
+
await handleGetReport(res, sessions, reportMatch[1]);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Favicon
|
|
52
|
+
if (url.pathname === '/favicon.svg') {
|
|
53
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
|
|
54
|
+
res.end(HERON_FAVICON_SVG);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Session detail page (HTML)
|
|
58
|
+
const sessionPageMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
59
|
+
if (sessionPageMatch && req.method === 'GET') {
|
|
60
|
+
await handleSessionPage(req, res, sessions, sessionPageMatch[1]);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Health check
|
|
64
|
+
if (url.pathname === '/health') {
|
|
65
|
+
json(res, 200, { status: 'ok', version: '0.1.0' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Landing page
|
|
69
|
+
if (url.pathname === '/') {
|
|
70
|
+
await handleLanding(res, sessions, req.headers.host ?? 'localhost:3700');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
json(res, 404, { error: 'Not found' });
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
logger.error(err instanceof Error ? err.message : String(err));
|
|
77
|
+
json(res, 500, { error: 'Internal server error' });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
server.listen(config.port, config.host, () => {
|
|
81
|
+
const baseUrl = `http://localhost:${config.port}`;
|
|
82
|
+
logger.raw('');
|
|
83
|
+
logger.raw(` \x1b[1mHeron Server\x1b[0m`);
|
|
84
|
+
logger.raw('');
|
|
85
|
+
logger.raw(` Dashboard: ${baseUrl}`);
|
|
86
|
+
logger.raw(` Agent URL: ${baseUrl}/v1/chat/completions`);
|
|
87
|
+
logger.raw(` Reports: ${config.reportDir}`);
|
|
88
|
+
logger.raw('');
|
|
89
|
+
logger.raw(` \x1b[1mHow to connect an agent:\x1b[0m`);
|
|
90
|
+
logger.raw('');
|
|
91
|
+
logger.raw(' Option 1 — Paste this prompt into your agent\'s chat:');
|
|
92
|
+
logger.raw('');
|
|
93
|
+
logger.raw(' ┌──────────────────────────────────────────────────────────────────────');
|
|
94
|
+
logger.raw(' │ I need you to complete a security access review for this project.');
|
|
95
|
+
logger.raw(' │ Make sequential API calls to the Heron endpoint until it says');
|
|
96
|
+
logger.raw(' │ "Interview complete".');
|
|
97
|
+
logger.raw(' │');
|
|
98
|
+
logger.raw(' │ Step 1 — Start the interview:');
|
|
99
|
+
logger.raw(` │ POST ${baseUrl}/v1/chat/completions`);
|
|
100
|
+
logger.raw(' │ Body: {"model":"any","messages":[{"role":"user",');
|
|
101
|
+
logger.raw(' │ "content":"Hi, I am ready to answer questions about this project."}]}');
|
|
102
|
+
logger.raw(' │');
|
|
103
|
+
logger.raw(' │ The response will contain a question and a session ID');
|
|
104
|
+
logger.raw(' │ (in the text and in the "heron_session_id" JSON field).');
|
|
105
|
+
logger.raw(' │');
|
|
106
|
+
logger.raw(' │ Step 2 — Send each answer:');
|
|
107
|
+
logger.raw(` │ POST ${baseUrl}/v1/chat/completions`);
|
|
108
|
+
logger.raw(' │ Body: {"model":"any","messages":[{"role":"user",');
|
|
109
|
+
logger.raw(' │ "content":"YOUR ANSWER HERE"}],');
|
|
110
|
+
logger.raw(' │ "heron_session_id":"SESSION_ID_FROM_STEP_1"}');
|
|
111
|
+
logger.raw(' │');
|
|
112
|
+
logger.raw(' │ Step 3 — Repeat step 2 with each new answer until the response');
|
|
113
|
+
logger.raw(' │ says "Interview complete".');
|
|
114
|
+
logger.raw(' │');
|
|
115
|
+
logger.raw(' │ Important: answer about THIS specific project — what you actually');
|
|
116
|
+
logger.raw(' │ do, what systems you connect to, what data you handle. Not general');
|
|
117
|
+
logger.raw(' │ capabilities. Never reveal actual secret values — just describe');
|
|
118
|
+
logger.raw(' │ credential types.');
|
|
119
|
+
logger.raw(' └──────────────────────────────────────────────────────────────────────');
|
|
120
|
+
logger.raw('');
|
|
121
|
+
logger.raw(' Option 2 — Set OPENAI_BASE_URL:');
|
|
122
|
+
logger.raw('');
|
|
123
|
+
logger.raw(` OPENAI_BASE_URL=${baseUrl}/v1 python your_agent.py`);
|
|
124
|
+
logger.raw('');
|
|
125
|
+
logger.success('Ready — waiting for agents to connect...');
|
|
126
|
+
logger.raw('');
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Extract session ID from assistant messages in conversation history */
|
|
130
|
+
function extractSessionFromMessages(messages) {
|
|
131
|
+
// Agents often send full conversation history — look for our session tag in assistant messages
|
|
132
|
+
for (const msg of messages) {
|
|
133
|
+
if (msg.role !== 'assistant')
|
|
134
|
+
continue;
|
|
135
|
+
const match = msg.content.match(/\[Session: (sess_[a-f0-9]+)/);
|
|
136
|
+
if (match)
|
|
137
|
+
return match[1];
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
const SYSTEM_GREETING = `You are now being interviewed by Heron, an open-source agent access auditor. I'll ask you questions about what you specifically do in the project you're deployed in — not your general capabilities, but your actual behavior, the actual systems you touch, and the actual data you handle. Answer with concrete specifics: real system names, real data types, real examples. Important: never reveal actual secret values (API keys, tokens, passwords) — just describe the type of credential and what it connects to. Let's begin.`;
|
|
142
|
+
async function handleChatCompletions(req, res, sessions) {
|
|
143
|
+
const body = await readBody(req);
|
|
144
|
+
const messages = body.messages ?? [];
|
|
145
|
+
const sessionId = req.headers['x-session-id']
|
|
146
|
+
?? body.heron_session_id
|
|
147
|
+
?? extractSessionFromMessages(messages)
|
|
148
|
+
?? null;
|
|
149
|
+
// Filter to user messages only (the agent's answers)
|
|
150
|
+
const userMessages = messages.filter(m => m.role === 'user');
|
|
151
|
+
if (userMessages.length === 0) {
|
|
152
|
+
// If a valid session already exists, return its pending question instead of creating a new one
|
|
153
|
+
if (sessionId) {
|
|
154
|
+
const existing = sessions.getSession(sessionId);
|
|
155
|
+
if (existing && existing.status === 'interviewing' && existing.pendingQuestion) {
|
|
156
|
+
chatResponse(res, existing.id, existing.pendingQuestion.text);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// No session — create one and return greeting + first question
|
|
161
|
+
const { session, firstQuestion } = sessions.createSession();
|
|
162
|
+
const reply = `${SYSTEM_GREETING}\n\n${firstQuestion}`;
|
|
163
|
+
chatResponse(res, session.id, reply);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Determine session: by header, or try to find/create
|
|
167
|
+
let session = sessionId ? sessions.getSession(sessionId) : null;
|
|
168
|
+
if (!session) {
|
|
169
|
+
// First real message from agent — create session and treat first user message as intro
|
|
170
|
+
const { session: newSession, firstQuestion } = sessions.createSession();
|
|
171
|
+
session = newSession;
|
|
172
|
+
if (userMessages.length === 1) {
|
|
173
|
+
// Agent just introduced itself — record it as answer to first question, get next
|
|
174
|
+
const result = await sessions.processAnswer(session.id, userMessages[0].content);
|
|
175
|
+
if (result.done && 'analyzing' in result) {
|
|
176
|
+
chatResponse(res, session.id, 'INTERVIEW COMPLETE.\n\nReport is being generated.', 'complete');
|
|
177
|
+
}
|
|
178
|
+
else if (result.done && 'report' in result) {
|
|
179
|
+
chatResponse(res, session.id, formatCompletion(result.report));
|
|
180
|
+
}
|
|
181
|
+
else if (!result.done) {
|
|
182
|
+
chatResponse(res, session.id, result.question);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Process the latest user message as an answer
|
|
188
|
+
const latestAnswer = userMessages[userMessages.length - 1].content;
|
|
189
|
+
const result = await sessions.processAnswer(session.id, latestAnswer);
|
|
190
|
+
if (result.done) {
|
|
191
|
+
if ('analyzing' in result) {
|
|
192
|
+
// Analysis running in background — tell agent to stop
|
|
193
|
+
chatResponse(res, session.id, 'INTERVIEW COMPLETE.\n\nThank you. The audit is finished. No more questions needed. You can stop making requests.\n\nThe report is being generated and will be available on the dashboard shortly.', 'complete');
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
chatResponse(res, session.id, formatCompletion(result.report), 'complete');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
chatResponse(res, session.id, result.question);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function formatCompletion(report) {
|
|
204
|
+
return `INTERVIEW COMPLETE.\n\nThank you. The audit is finished. No more questions needed. You can stop making requests.\n\nHere is your audit report:\n\n${report}`;
|
|
205
|
+
}
|
|
206
|
+
function chatResponse(res, sessionId, content, status) {
|
|
207
|
+
// Embed session ID in the text so agents can reliably extract it
|
|
208
|
+
// Agents read the text content — headers and custom JSON fields are often ignored
|
|
209
|
+
const sessionLine = status === 'complete'
|
|
210
|
+
? ''
|
|
211
|
+
: `\n\n[Session: ${sessionId} — include this in your next request as X-Session-Id header or heron_session_id body field]`;
|
|
212
|
+
json(res, 200, {
|
|
213
|
+
id: `chatcmpl-${sessionId}`,
|
|
214
|
+
object: 'chat.completion',
|
|
215
|
+
created: Math.floor(Date.now() / 1000),
|
|
216
|
+
model: 'heron-interrogator',
|
|
217
|
+
choices: [
|
|
218
|
+
{
|
|
219
|
+
index: 0,
|
|
220
|
+
message: {
|
|
221
|
+
role: 'assistant',
|
|
222
|
+
content: content + sessionLine,
|
|
223
|
+
},
|
|
224
|
+
finish_reason: 'stop',
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
228
|
+
// Custom: session tracking (also in body for programmatic access)
|
|
229
|
+
heron_session_id: sessionId,
|
|
230
|
+
...(status ? { heron_status: status } : {}),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// ─── REST handlers ────────────────────────────────────────────────────────
|
|
234
|
+
async function handleListSessions(res, sessions) {
|
|
235
|
+
const list = sessions.listSessions().map(s => ({
|
|
236
|
+
id: s.id,
|
|
237
|
+
status: s.status,
|
|
238
|
+
questionsAsked: s.questionsAsked,
|
|
239
|
+
createdAt: s.createdAt.toISOString(),
|
|
240
|
+
updatedAt: s.updatedAt.toISOString(),
|
|
241
|
+
riskLevel: s.reportJson?.overallRiskLevel ?? null,
|
|
242
|
+
}));
|
|
243
|
+
json(res, 200, { sessions: list });
|
|
244
|
+
}
|
|
245
|
+
async function handleGetSession(res, sessions, id) {
|
|
246
|
+
const session = sessions.getSession(id);
|
|
247
|
+
if (!session) {
|
|
248
|
+
json(res, 404, { error: 'Session not found' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
json(res, 200, {
|
|
252
|
+
id: session.id,
|
|
253
|
+
status: session.status,
|
|
254
|
+
questionsAsked: session.questionsAsked,
|
|
255
|
+
createdAt: session.createdAt.toISOString(),
|
|
256
|
+
updatedAt: session.updatedAt.toISOString(),
|
|
257
|
+
transcript: session.protocol.getTranscript(),
|
|
258
|
+
riskLevel: session.reportJson?.overallRiskLevel ?? null,
|
|
259
|
+
error: session.error ?? null,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async function handleGetReport(res, sessions, id) {
|
|
263
|
+
const session = sessions.getSession(id);
|
|
264
|
+
if (!session) {
|
|
265
|
+
json(res, 404, { error: 'Session not found' });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (session.status !== 'complete') {
|
|
269
|
+
json(res, 409, {
|
|
270
|
+
error: `Session is still "${session.status}". Report not ready yet.`,
|
|
271
|
+
questionsAsked: session.questionsAsked,
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Return markdown as a downloadable file
|
|
276
|
+
res.writeHead(200, {
|
|
277
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
278
|
+
'Content-Disposition': `attachment; filename="heron-report-${id}.md"`,
|
|
279
|
+
'X-Content-Type-Options': 'nosniff',
|
|
280
|
+
});
|
|
281
|
+
res.end(session.report);
|
|
282
|
+
}
|
|
283
|
+
// ─── Shared UI components ────────────────────────────────────────────────
|
|
284
|
+
// Exact copy of .github/heron-logo.svg — inlined so it works in Docker builds
|
|
285
|
+
const HERON_FAVICON_SVG = `<?xml version="1.0" standalone="no"?>
|
|
286
|
+
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
|
287
|
+
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000" preserveAspectRatio="xMidYMid meet">
|
|
288
|
+
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
|
|
289
|
+
<path d="M5215 8809 c-197 -172 -573 -433 -855 -594 -602 -344 -1270 -553 -1890 -593 l-105 -7 0 -1480 c0 -1603 -2 -1546 56 -1844 111 -567 393 -1107 817 -1561 337 -362 724 -659 1247 -959 163 -94 562 -298 714 -366 101 -45 77 -50 338 76 451 216 888 476 1208 717 382 286 719 632 938 961 258 387 441 854 501 1276 34 241 36 343 36 1756 l0 1417 -22 6 c-13 3 -43 6 -68 6 -154 0 -596 79 -872 156 -622 172 -1297 536 -1855 1001 -62 51 -113 93 -115 93 -2 0 -34 -28 -73 -61z m115 -120 c19 -16 105 -83 190 -148 492 -373 976 -633 1510 -811 308 -103 540 -154 903 -202 l178 -23 -4 -1425 c-3 -1019 -7 -1449 -16 -1510 -52 -382 -141 -675 -297 -990 -129 -260 -253 -444 -455 -680 -411 -479 -973 -882 -1800 -1290 l-245 -121 -45 19 c-189 82 -630 313 -839 439 -389 235 -654 436 -944 714 -592 568 -932 1271 -986 2039 -6 87 -10 676 -10 1476 l0 1331 73 7 c657 63 1281 265 1887 611 245 139 570 362 783 536 40 33 74 59 77 59 3 0 21 -14 40 -31z"/>
|
|
290
|
+
<path d="M5205 8396 c-398 -307 -784 -539 -1205 -724 -369 -162 -774 -276 -1198 -338 l-123 -18 4 -1365 c3 -1358 3 -1367 25 -1481 50 -259 106 -456 184 -640 229 -540 599 -986 1168 -1407 264 -195 661 -428 1054 -619 l176 -85 227 113 c680 340 1136 651 1515 1035 197 200 274 295 418 515 238 363 389 786 435 1218 12 111 15 369 15 1427 l0 1292 -57 6 c-119 14 -434 77 -598 120 -647 168 -1260 477 -1860 939 -44 34 -84 65 -90 68 -5 3 -46 -22 -90 -56z m208 -150 c524 -400 1122 -702 1735 -875 141 -40 494 -114 605 -127 l58 -7 -4 -1291 c-4 -1433 1 -1338 -73 -1655 -192 -825 -739 -1491 -1699 -2068 -178 -107 -422 -239 -611 -330 l-138 -66 -205 102 c-533 266 -943 530 -1278 825 -316 277 -547 567 -720 903 -152 292 -235 553 -290 903 -17 106 -18 212 -18 1394 l0 1280 163 27 c794 132 1523 455 2222 983 69 52 126 95 128 95 1 1 57 -41 125 -93z"/>
|
|
291
|
+
<path d="M5542 7460 c-94 -25 -165 -57 -337 -151 -199 -109 -317 -145 -442 -132 -51 6 -57 4 -45 -9 22 -27 97 -41 180 -35 93 8 173 32 267 79 l70 36 -95 -97 c-52 -53 -110 -122 -129 -153 -53 -91 -91 -212 -90 -288 0 -73 0 -73 52 67 18 50 49 118 68 150 32 54 214 251 224 242 2 -3 -7 -28 -21 -57 -61 -127 -70 -321 -21 -454 56 -155 138 -246 303 -338 177 -99 237 -164 270 -290 37 -145 -25 -266 -127 -246 -19 3 -61 27 -94 52 -168 128 -329 153 -540 83 -173 -58 -347 -180 -540 -380 -187 -194 -318 -386 -464 -684 -106 -215 -150 -321 -230 -560 -60 -179 -136 -454 -127 -462 9 -9 44 8 125 61 44 30 81 53 81 52 0 -1 -15 -47 -33 -102 -19 -54 -37 -116 -41 -136 l-7 -38 28 11 c86 32 200 98 284 163 85 66 150 132 294 300 38 44 47 48 155 80 63 19 152 47 198 64 45 16 84 27 86 23 2 -3 7 -51 11 -106 6 -80 4 -112 -10 -160 -60 -217 -90 -315 -106 -353 -25 -59 -24 -80 16 -277 51 -257 139 -800 132 -818 -5 -15 -24 -17 -132 -17 -150 0 -195 -16 -195 -71 0 -5 268 -9 665 -9 366 0 665 2 665 5 0 18 -36 55 -62 64 -17 6 -99 11 -182 11 -196 0 -210 6 -243 105 -13 39 -53 176 -89 305 -36 129 -83 298 -106 375 -38 130 -40 148 -39 255 1 91 10 157 42 315 38 183 47 212 108 350 37 82 84 198 105 256 36 100 40 108 89 145 29 21 101 88 160 149 103 105 107 108 88 65 -63 -144 -101 -424 -70 -530 l12 -45 8 30 c3 17 7 50 8 75 3 107 62 327 124 465 42 95 107 218 112 213 2 -2 -5 -35 -15 -75 -31 -121 -43 -260 -30 -344 13 -87 30 -114 30 -48 0 122 45 322 110 489 76 195 141 384 162 470 28 111 30 340 4 444 -65 267 -225 462 -468 569 -62 28 -122 77 -144 118 -18 36 -18 122 1 159 8 16 29 39 47 52 31 22 45 23 233 29 155 5 218 11 279 27 l79 21 331 -24 c360 -27 426 -30 426 -16 0 5 -22 18 -50 30 -48 21 -647 221 -723 242 -21 5 -66 34 -100 62 -95 80 -120 96 -210 129 -110 41 -263 49 -375 18z m436 -154 c32 -12 89 -42 127 -65 48 -28 133 -62 273 -107 111 -37 201 -68 200 -70 -4 -3 -191 27 -414 66 -130 23 -198 26 -192 8 4 -12 85 -49 133 -62 15 -4 17 -8 8 -17 -8 -8 -66 -11 -190 -10 -98 0 -201 -4 -230 -10 -169 -34 -241 -223 -142 -373 32 -48 79 -81 204 -145 44 -22 106 -61 137 -86 231 -182 327 -557 222 -872 -63 -189 -210 -400 -384 -551 -86 -74 -289 -217 -297 -208 -2 2 4 21 15 43 35 69 99 286 112 380 23 165 0 263 -79 342 -97 97 -254 90 -437 -20 -69 -42 -164 -126 -164 -145 0 -23 24 -16 91 27 141 92 236 129 329 129 76 0 126 -30 159 -95 70 -136 0 -369 -174 -576 -53 -64 -181 -179 -199 -179 -3 0 3 19 15 42 54 105 99 265 99 349 0 32 -3 40 -16 37 -11 -2 -23 -29 -39 -88 -71 -272 -182 -433 -364 -524 -56 -29 -56 -27 -6 44 69 97 138 259 150 353 6 48 5 57 -8 57 -9 0 -22 -19 -33 -47 -79 -216 -171 -364 -296 -477 -54 -50 -186 -126 -217 -126 -7 0 11 28 41 63 76 88 131 172 194 292 51 99 65 147 45 159 -12 8 -35 -21 -66 -84 -49 -98 -152 -243 -239 -336 -73 -79 -225 -216 -264 -238 -20 -12 157 335 232 454 65 103 72 120 60 133 -16 15 -49 -22 -128 -141 -91 -137 -164 -268 -237 -426 -45 -97 -47 -101 -128 -159 -45 -33 -83 -58 -85 -56 -3 2 17 76 44 164 49 164 162 453 243 628 248 529 649 958 999 1065 179 56 304 33 450 -81 94 -74 180 -85 258 -33 74 49 115 183 91 295 -31 144 -120 249 -302 354 -143 82 -207 143 -253 240 -58 122 -69 220 -40 350 22 97 48 149 106 212 87 96 214 135 347 106 49 -10 54 -9 89 14 46 31 77 31 150 1z m-816 -3151 c1 -52 -68 -386 -96 -464 -23 -63 -20 -93 19 -211 43 -131 245 -857 245 -882 0 -17 -11 -18 -153 -18 -136 0 -156 2 -170 18 -30 33 -28 20 -137 661 -36 210 -40 245 -34 345 8 165 74 394 165 582 38 77 83 162 101 190 l32 49 13 -110 c8 -60 14 -132 15 -160z"/>
|
|
292
|
+
<path d="M5861 7271 c-21 -14 -18 -69 5 -86 23 -17 39 -18 65 -5 22 12 27 68 7 88 -14 14 -57 16 -77 3z"/>
|
|
293
|
+
</g>
|
|
294
|
+
</svg>`;
|
|
295
|
+
const FAVICON_LINK = `<link rel="icon" type="image/svg+xml" href="/favicon.svg">`;
|
|
296
|
+
const HERON_LOGO = `<svg width="36" height="36" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0,1024) scale(0.1,-0.1)" fill="#0f172a" stroke="none"><path d="M5215 8809 c-197 -172 -573 -433 -855 -594 -602 -344 -1270 -553 -1890 -593 l-105 -7 0 -1480 c0 -1603 -2 -1546 56 -1844 111 -567 393 -1107 817 -1561 337 -362 724 -659 1247 -959 163 -94 562 -298 714 -366 101 -45 77 -50 338 76 451 216 888 476 1208 717 382 286 719 632 938 961 258 387 441 854 501 1276 34 241 36 343 36 1756 l0 1417 -22 6 c-13 3 -43 6 -68 6 -154 0 -596 79 -872 156 -622 172 -1297 536 -1855 1001 -62 51 -113 93 -115 93 -2 0 -34 -28 -73 -61z m115 -120 c19 -16 105 -83 190 -148 492 -373 976 -633 1510 -811 308 -103 540 -154 903 -202 l178 -23 -4 -1425 c-3 -1019 -7 -1449 -16 -1510 -52 -382 -141 -675 -297 -990 -129 -260 -253 -444 -455 -680 -411 -479 -973 -882 -1800 -1290 l-245 -121 -45 19 c-189 82 -630 313 -839 439 -389 235 -654 436 -944 714 -592 568 -932 1271 -986 2039 -6 87 -10 676 -10 1476 l0 1331 73 7 c657 63 1281 265 1887 611 245 139 570 362 783 536 40 33 74 59 77 59 3 0 21 -14 40 -31z"/><path d="M5205 8396 c-398 -307 -784 -539 -1205 -724 -369 -162 -774 -276 -1198 -338 l-123 -18 4 -1365 c3 -1358 3 -1367 25 -1481 50 -259 106 -456 184 -640 229 -540 599 -986 1168 -1407 264 -195 661 -428 1054 -619 l176 -85 227 113 c680 340 1136 651 1515 1035 197 200 274 295 418 515 238 363 389 786 435 1218 12 111 15 369 15 1427 l0 1292 -57 6 c-119 14 -434 77 -598 120 -647 168 -1260 477 -1860 939 -44 34 -84 65 -90 68 -5 3 -46 -22 -90 -56z m208 -150 c524 -400 1122 -702 1735 -875 141 -40 494 -114 605 -127 l58 -7 -4 -1291 c-4 -1433 1 -1338 -73 -1655 -192 -825 -739 -1491 -1699 -2068 -178 -107 -422 -239 -611 -330 l-138 -66 -205 102 c-533 266 -943 530 -1278 825 -316 277 -547 567 -720 903 -152 292 -235 553 -290 903 -17 106 -18 212 -18 1394 l0 1280 163 27 c794 132 1523 455 2222 983 69 52 126 95 128 95 1 1 57 -41 125 -93z"/><path d="M5542 7460 c-94 -25 -165 -57 -337 -151 -199 -109 -317 -145 -442 -132 -51 6 -57 4 -45 -9 22 -27 97 -41 180 -35 93 8 173 32 267 79 l70 36 -95 -97 c-52 -53 -110 -122 -129 -153 -53 -91 -91 -212 -90 -288 0 -73 0 -73 52 67 18 50 49 118 68 150 32 54 214 251 224 242 2 -3 -7 -28 -21 -57 -61 -127 -70 -321 -21 -454 56 -155 138 -246 303 -338 177 -99 237 -164 270 -290 37 -145 -25 -266 -127 -246 -19 3 -61 27 -94 52 -168 128 -329 153 -540 83 -173 -58 -347 -180 -540 -380 -187 -194 -318 -386 -464 -684 -106 -215 -150 -321 -230 -560 -60 -179 -136 -454 -127 -462 9 -9 44 8 125 61 44 30 81 53 81 52 0 -1 -15 -47 -33 -102 -19 -54 -37 -116 -41 -136 l-7 -38 28 11 c86 32 200 98 284 163 85 66 150 132 294 300 38 44 47 48 155 80 63 19 152 47 198 64 45 16 84 27 86 23 2 -3 7 -51 11 -106 6 -80 4 -112 -10 -160 -60 -217 -90 -315 -106 -353 -25 -59 -24 -80 16 -277 51 -257 139 -800 132 -818 -5 -15 -24 -17 -132 -17 -150 0 -195 -16 -195 -71 0 -5 268 -9 665 -9 366 0 665 2 665 5 0 18 -36 55 -62 64 -17 6 -99 11 -182 11 -196 0 -210 6 -243 105 -13 39 -53 176 -89 305 -36 129 -83 298 -106 375 -38 130 -40 148 -39 255 1 91 10 157 42 315 38 183 47 212 108 350 37 82 84 198 105 256 36 100 40 108 89 145 29 21 101 88 160 149 103 105 107 108 88 65 -63 -144 -101 -424 -70 -530 l12 -45 8 30 c3 17 7 50 8 75 3 107 62 327 124 465 42 95 107 218 112 213 2 -2 -5 -35 -15 -75 -31 -121 -43 -260 -30 -344 13 -87 30 -114 30 -48 0 122 45 322 110 489 76 195 141 384 162 470 28 111 30 340 4 444 -65 267 -225 462 -468 569 -62 28 -122 77 -144 118 -18 36 -18 122 1 159 8 16 29 39 47 52 31 22 45 23 233 29 155 5 218 11 279 27 l79 21 331 -24 c360 -27 426 -30 426 -16 0 5 -22 18 -50 30 -48 21 -647 221 -723 242 -21 5 -66 34 -100 62 -95 80 -120 96 -210 129 -110 41 -263 49 -375 18z m436 -154 c32 -12 89 -42 127 -65 48 -28 133 -62 273 -107 111 -37 201 -68 200 -70 -4 -3 -191 27 -414 66 -130 23 -198 26 -192 8 4 -12 85 -49 133 -62 15 -4 17 -8 8 -17 -8 -8 -66 -11 -190 -10 -98 0 -201 -4 -230 -10 -169 -34 -241 -223 -142 -373 32 -48 79 -81 204 -145 44 -22 106 -61 137 -86 231 -182 327 -557 222 -872 -63 -189 -210 -400 -384 -551 -86 -74 -289 -217 -297 -208 -2 2 4 21 15 43 35 69 99 286 112 380 23 165 0 263 -79 342 -97 97 -254 90 -437 -20 -69 -42 -164 -126 -164 -145 0 -23 24 -16 91 27 141 92 236 129 329 129 76 0 126 -30 159 -95 70 -136 0 -369 -174 -576 -53 -64 -181 -179 -199 -179 -3 0 3 19 15 42 54 105 99 265 99 349 0 32 -3 40 -16 37 -11 -2 -23 -29 -39 -88 -71 -272 -182 -433 -364 -524 -56 -29 -56 -27 -6 44 69 97 138 259 150 353 6 48 5 57 -8 57 -9 0 -22 -19 -33 -47 -79 -216 -171 -364 -296 -477 -54 -50 -186 -126 -217 -126 -7 0 11 28 41 63 76 88 131 172 194 292 51 99 65 147 45 159 -12 8 -35 -21 -66 -84 -49 -98 -152 -243 -239 -336 -73 -79 -225 -216 -264 -238 -20 -12 157 335 232 454 65 103 72 120 60 133 -16 15 -49 -22 -128 -141 -91 -137 -164 -268 -237 -426 -45 -97 -47 -101 -128 -159 -45 -33 -83 -58 -85 -56 -3 2 17 76 44 164 49 164 162 453 243 628 248 529 649 958 999 1065 179 56 304 33 450 -81 94 -74 180 -85 258 -33 74 49 115 183 91 295 -31 144 -120 249 -302 354 -143 82 -207 143 -253 240 -58 122 -69 220 -40 350 22 97 48 149 106 212 87 96 214 135 347 106 49 -10 54 -9 89 14 46 31 77 31 150 1z m-816 -3151 c1 -52 -68 -386 -96 -464 -23 -63 -20 -93 19 -211 43 -131 245 -857 245 -882 0 -17 -11 -18 -153 -18 -136 0 -156 2 -170 18 -30 33 -28 20 -137 661 -36 210 -40 245 -34 345 8 165 74 394 165 582 38 77 83 162 101 190 l32 49 13 -110 c8 -60 14 -132 15 -160z"/><path d="M5861 7271 c-21 -14 -18 -69 5 -86 23 -17 39 -18 65 -5 22 12 27 68 7 88 -14 14 -57 16 -77 3z"/></g></svg>`;
|
|
297
|
+
const SHARED_CSS = `
|
|
298
|
+
* { box-sizing: border-box; }
|
|
299
|
+
body { font-family: -apple-system, system-ui, 'Segoe UI', sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; color: #1a1a1a; background: #fafbfc; }
|
|
300
|
+
a { color: #2563eb; text-decoration: none; }
|
|
301
|
+
a:hover { text-decoration: underline; }
|
|
302
|
+
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }
|
|
303
|
+
pre { background: #1a1a2e; color: #e0e0e0; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 0.85em; }
|
|
304
|
+
|
|
305
|
+
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; justify-content: center; }
|
|
306
|
+
.header h1 { font-size: 1.4rem; margin: 0; }
|
|
307
|
+
.header-sub { color: #6b7280; margin: 0 0 32px 0; font-size: 0.95em; text-align: center; }
|
|
308
|
+
.footer { margin-top: 48px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 0.8em; text-align: center; }
|
|
309
|
+
.footer a { color: #9ca3af; }
|
|
310
|
+
|
|
311
|
+
.badge { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 0.8em; font-weight: 600; }
|
|
312
|
+
.badge-interviewing { background: #fef3c7; color: #92400e; }
|
|
313
|
+
.badge-analyzing { background: #dbeafe; color: #1e40af; }
|
|
314
|
+
.badge-complete { background: #d1fae5; color: #065f46; }
|
|
315
|
+
.badge-error { background: #fee2e2; color: #991b1b; }
|
|
316
|
+
.risk { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 0.8em; font-weight: 700; }
|
|
317
|
+
.risk-low { background: #d1fae5; color: #065f46; }
|
|
318
|
+
.risk-medium { background: #fef3c7; color: #92400e; }
|
|
319
|
+
.risk-high { background: #fee2e2; color: #991b1b; }
|
|
320
|
+
.risk-critical { background: #991b1b; color: #fff; }
|
|
321
|
+
|
|
322
|
+
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; border: 1px solid #e5e7eb; }
|
|
323
|
+
th { background: #f9fafb; font-weight: 600; font-size: 0.85em; text-transform: uppercase; color: #6b7280; letter-spacing: 0.03em; }
|
|
324
|
+
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid #e5e7eb; }
|
|
325
|
+
tbody tr:hover { background: #f0f7ff; }
|
|
326
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
327
|
+
|
|
328
|
+
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
|
329
|
+
.empty { color: #6b7280; padding: 40px 0; text-align: center; }
|
|
330
|
+
|
|
331
|
+
.qa { margin-bottom: 16px; padding: 12px 16px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; }
|
|
332
|
+
.q { font-weight: 600; margin-bottom: 6px; line-height: 1.5; }
|
|
333
|
+
.a { color: #374151; white-space: pre-wrap; line-height: 1.5; }
|
|
334
|
+
.cat { display: inline-block; background: #eff6ff; padding: 1px 8px; border-radius: 3px; font-size: 0.7em; color: #3b82f6; font-weight: 600; margin-right: 8px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
335
|
+
|
|
336
|
+
.report-rendered { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 32px; line-height: 1.6; }
|
|
337
|
+
.report-rendered h1 { font-size: 1.5em; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px; }
|
|
338
|
+
.report-rendered h2 { font-size: 1.2em; margin-top: 28px; border-bottom: 1px solid #f0f0f0; padding-bottom: 6px; color: #1e293b; }
|
|
339
|
+
.report-rendered table { margin: 12px 0; font-size: 0.9em; }
|
|
340
|
+
.report-rendered p { margin: 8px 0; }
|
|
341
|
+
.report-rendered strong { color: #0f172a; }
|
|
342
|
+
.report-rendered hr { border: none; border-top: 1px solid #e5e7eb; margin: 24px 0; }
|
|
343
|
+
.report-rendered ol, .report-rendered ul { padding-left: 24px; }
|
|
344
|
+
.report-rendered li { margin: 4px 0; }
|
|
345
|
+
.report-rendered details { margin-top: 16px; }
|
|
346
|
+
.report-rendered summary { cursor: pointer; font-weight: 600; color: #2563eb; }
|
|
347
|
+
.report-rendered blockquote { border-left: 3px solid #f59e0b; background: #fffbeb; padding: 12px 16px; margin: 12px 0; border-radius: 0 6px 6px 0; color: #92400e; }
|
|
348
|
+
.report-rendered h3 { font-size: 1.05em; margin-top: 20px; color: #334155; }
|
|
349
|
+
|
|
350
|
+
.copy-block { position: relative; }
|
|
351
|
+
.copy-block pre { white-space: pre-wrap; word-break: break-all; overflow-x: hidden; }
|
|
352
|
+
.copy-btn { position: absolute; top: 8px; right: 8px; background: #374151; color: #e5e7eb; border: 1px solid #4b5563; padding: 6px; border-radius: 4px; cursor: pointer; opacity: 0.7; transition: opacity 0.15s; display: flex; align-items: center; justify-content: center; }
|
|
353
|
+
.copy-btn:hover { opacity: 1; background: #4b5563; }
|
|
354
|
+
.copy-btn.copied { background: #065f46; border-color: #065f46; color: #d1fae5; }
|
|
355
|
+
.copy-btn svg { width: 16px; height: 16px; }
|
|
356
|
+
|
|
357
|
+
.btn { display: inline-block; background: #2563eb; color: #fff; padding: 8px 16px; border-radius: 6px; font-size: 0.9em; font-weight: 500; }
|
|
358
|
+
.btn:hover { background: #1d4ed8; text-decoration: none; }
|
|
359
|
+
.btn-outline { background: transparent; color: #2563eb; border: 1px solid #2563eb; }
|
|
360
|
+
.btn-outline:hover { background: #eff6ff; }
|
|
361
|
+
.report-actions { margin-bottom: 20px; display: flex; gap: 10px; }
|
|
362
|
+
.meta { color: #6b7280; margin-bottom: 24px; }
|
|
363
|
+
.analyzing { color: #1e40af; font-style: italic; }
|
|
364
|
+
.error-msg { color: #991b1b; background: #fee2e2; padding: 12px; border-radius: 6px; }
|
|
365
|
+
`;
|
|
366
|
+
function markdownToHtml(md) {
|
|
367
|
+
let html = escapeHtml(md);
|
|
368
|
+
// Horizontal rules
|
|
369
|
+
html = html.replace(/^---$/gm, '<hr>');
|
|
370
|
+
// Headers
|
|
371
|
+
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
372
|
+
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
373
|
+
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
374
|
+
// Bold
|
|
375
|
+
html = html.replace(/\*\*\[([A-Z]+)\]\s*(.+?)\*\*/g, '<strong>[$1] $2</strong>');
|
|
376
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
377
|
+
// Details/summary
|
|
378
|
+
html = html.replace(/<details>/g, '<details>');
|
|
379
|
+
html = html.replace(/<\/details>/g, '</details>');
|
|
380
|
+
html = html.replace(/<summary>(.+?)<\/summary>/g, '<summary>$1</summary>');
|
|
381
|
+
// Tables
|
|
382
|
+
html = html.replace(/((?:^\|.+\|$\n?)+)/gm, (tableBlock) => {
|
|
383
|
+
const rows = tableBlock.trim().split('\n').filter(r => !r.match(/^\|[\s\-:|]+\|$/));
|
|
384
|
+
if (rows.length === 0)
|
|
385
|
+
return tableBlock;
|
|
386
|
+
const parseRow = (row) => row.split('|').slice(1, -1).map(c => c.trim());
|
|
387
|
+
const headerCells = parseRow(rows[0]);
|
|
388
|
+
const thead = '<thead><tr>' + headerCells.map(c => `<th>${c}</th>`).join('') + '</tr></thead>';
|
|
389
|
+
const tbody = rows.slice(1).map(row => {
|
|
390
|
+
const cells = parseRow(row);
|
|
391
|
+
return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
|
|
392
|
+
}).join('');
|
|
393
|
+
return `<table>${thead}<tbody>${tbody}</tbody></table>`;
|
|
394
|
+
});
|
|
395
|
+
// Blockquotes
|
|
396
|
+
html = html.replace(/((?:^> .+$\n?)+)/gm, (block) => {
|
|
397
|
+
const content = block.replace(/^> /gm, '').trim();
|
|
398
|
+
return `<blockquote>${content}</blockquote>`;
|
|
399
|
+
});
|
|
400
|
+
// Unordered lists
|
|
401
|
+
html = html.replace(/((?:^- .+$\n?)+)/gm, (block) => {
|
|
402
|
+
const items = block.trim().split('\n').map(l => {
|
|
403
|
+
const content = l.replace(/^- /, '');
|
|
404
|
+
return `<li>${content}</li>`;
|
|
405
|
+
}).join('');
|
|
406
|
+
return `<ul>${items}</ul>`;
|
|
407
|
+
});
|
|
408
|
+
// Ordered lists
|
|
409
|
+
html = html.replace(/((?:^\d+\. .+$\n?)+)/gm, (block) => {
|
|
410
|
+
const items = block.trim().split('\n').map(l => `<li>${l.replace(/^\d+\.\s+/, '')}</li>`).join('');
|
|
411
|
+
return `<ol>${items}</ol>`;
|
|
412
|
+
});
|
|
413
|
+
// Italic (single *)
|
|
414
|
+
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
415
|
+
// Paragraphs — wrap remaining loose text lines
|
|
416
|
+
html = html.replace(/^(?!<[htouda\-bl]|<li|<hr|<str|<sum|<det|<ul|<ol|<em|$)(.+)$/gm, '<p>$1</p>');
|
|
417
|
+
// Clean up empty paragraphs
|
|
418
|
+
html = html.replace(/<p>\s*<\/p>/g, '');
|
|
419
|
+
return html;
|
|
420
|
+
}
|
|
421
|
+
async function handleSessionPage(req, res, sessions, id) {
|
|
422
|
+
const session = sessions.getSession(id);
|
|
423
|
+
if (!session) {
|
|
424
|
+
json(res, 404, { error: 'Session not found' });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const transcript = session.protocol.getTranscript();
|
|
428
|
+
const riskBadge = session.reportJson?.overallRiskLevel
|
|
429
|
+
? `<span class="risk risk-${session.reportJson.overallRiskLevel}">${session.reportJson.overallRiskLevel.toUpperCase()}</span>`
|
|
430
|
+
: '';
|
|
431
|
+
const transcriptHtml = transcript.map((qa) => `
|
|
432
|
+
<div class="qa">
|
|
433
|
+
<div class="q"><span class="cat">${qa.category}</span> ${escapeHtml(qa.question)}</div>
|
|
434
|
+
<div class="a">${escapeHtml(qa.answer)}</div>
|
|
435
|
+
</div>
|
|
436
|
+
`).join('');
|
|
437
|
+
const reportSection = session.status === 'complete' && session.report
|
|
438
|
+
? `<h2>Report</h2>
|
|
439
|
+
<div class="report-actions">
|
|
440
|
+
<a href="/api/sessions/${id}/report" class="btn btn-outline">Download Markdown</a>
|
|
441
|
+
</div>
|
|
442
|
+
<div class="report-rendered">${markdownToHtml(session.report)}</div>`
|
|
443
|
+
: session.status === 'analyzing'
|
|
444
|
+
? '<h2>Report</h2><p class="analyzing">Analyzing interview...</p>'
|
|
445
|
+
: session.status === 'error'
|
|
446
|
+
? `<h2>Report</h2><p class="error-msg">Error: ${escapeHtml(session.error ?? 'Unknown error')}</p>`
|
|
447
|
+
: '';
|
|
448
|
+
const html = `<!DOCTYPE html>
|
|
449
|
+
<html>
|
|
450
|
+
<head><title>Heron</title>${FAVICON_LINK}
|
|
451
|
+
<style>${SHARED_CSS}</style>
|
|
452
|
+
|
|
453
|
+
</head>
|
|
454
|
+
<body>
|
|
455
|
+
<div class="header">${HERON_LOGO}<h1>Heron</h1></div>
|
|
456
|
+
<p style="margin: 0 0 24px 0;"><a href="/">← All sessions</a></p>
|
|
457
|
+
|
|
458
|
+
<h2>Session <code>${id}</code> <span class="badge badge-${session.status}" id="session-status">${session.status}</span> ${riskBadge}</h2>
|
|
459
|
+
<div class="meta" id="session-meta">${session.questionsAsked} questions · started ${session.createdAt.toISOString().slice(0, 19).replace('T', ' ')} UTC</div>
|
|
460
|
+
|
|
461
|
+
<div id="report-section">${reportSection}</div>
|
|
462
|
+
|
|
463
|
+
<h2>Interview Transcript (<span id="qa-count">${transcript.length}</span> Q&A)</h2>
|
|
464
|
+
<div id="transcript-body">${transcript.length === 0 ? '<p>Waiting for agent to respond...</p>' : transcriptHtml}</div>
|
|
465
|
+
|
|
466
|
+
<div class="footer">Powered by <a href="https://github.com/jonydony/Heron">Heron</a> — open-source agent checkpoint</div>
|
|
467
|
+
${session.status === 'interviewing' || session.status === 'analyzing' ? `<script>
|
|
468
|
+
(function() {
|
|
469
|
+
var polling = setInterval(function() {
|
|
470
|
+
fetch('/api/sessions/${id}').then(function(r) { return r.json(); }).then(function(data) {
|
|
471
|
+
if (!data) return;
|
|
472
|
+
var statusEl = document.getElementById('session-status');
|
|
473
|
+
if (statusEl && statusEl.textContent !== data.status) {
|
|
474
|
+
statusEl.textContent = data.status;
|
|
475
|
+
statusEl.className = 'badge badge-' + data.status;
|
|
476
|
+
}
|
|
477
|
+
var metaEl = document.getElementById('session-meta');
|
|
478
|
+
if (metaEl) metaEl.textContent = data.questionsAsked + ' questions \\u00b7 started ' + data.createdAt.slice(0,19).replace('T',' ') + ' UTC';
|
|
479
|
+
if (data.status === 'complete' || data.status === 'error') {
|
|
480
|
+
clearInterval(polling);
|
|
481
|
+
location.reload(); // one final reload to get the full report
|
|
482
|
+
}
|
|
483
|
+
}).catch(function() {});
|
|
484
|
+
}, 3000);
|
|
485
|
+
})();
|
|
486
|
+
</script>` : ''}
|
|
487
|
+
</body>
|
|
488
|
+
</html>`;
|
|
489
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'X-Content-Type-Options': 'nosniff' });
|
|
490
|
+
res.end(html);
|
|
491
|
+
}
|
|
492
|
+
async function handleLanding(res, sessions, host) {
|
|
493
|
+
const activeSessions = sessions.listSessions();
|
|
494
|
+
const baseUrl = host.includes('localhost') || host.includes('0.0.0.0')
|
|
495
|
+
? `http://localhost:${3700}`
|
|
496
|
+
: `https://${host}`;
|
|
497
|
+
const html = `<!DOCTYPE html>
|
|
498
|
+
<html>
|
|
499
|
+
<head><title>Heron</title>${FAVICON_LINK}
|
|
500
|
+
<style>${SHARED_CSS}</style>
|
|
501
|
+
|
|
502
|
+
</head>
|
|
503
|
+
<body>
|
|
504
|
+
<div class="header">${HERON_LOGO}<h1>Heron</h1></div>
|
|
505
|
+
<p class="header-sub">Vet AI agents before they get production access</p>
|
|
506
|
+
|
|
507
|
+
<h2>Sessions (<span id="session-count">${activeSessions.length}</span>)</h2>
|
|
508
|
+
<div id="sessions-table">${activeSessions.length === 0
|
|
509
|
+
? '<div class="empty"><p>No sessions yet.</p><p>Connect an agent to <code>/v1/chat/completions</code> to start an interview.</p></div>'
|
|
510
|
+
: `<table>
|
|
511
|
+
<thead><tr><th>Session</th><th>Status</th><th>Questions</th><th>Risk</th><th>Started</th></tr></thead>
|
|
512
|
+
<tbody>
|
|
513
|
+
${activeSessions.map(s => `<tr data-id="${s.id}">
|
|
514
|
+
<td><a href="/sessions/${s.id}"><code>${s.id}</code></a></td>
|
|
515
|
+
<td><span class="badge badge-${s.status}">${s.status}</span></td>
|
|
516
|
+
<td>${s.questionsAsked}</td>
|
|
517
|
+
<td>${s.reportJson?.overallRiskLevel ? `<span class="risk risk-${s.reportJson.overallRiskLevel}">${s.reportJson.overallRiskLevel.toUpperCase()}</span>` : '—'}</td>
|
|
518
|
+
<td>${s.createdAt.toISOString().slice(0, 19).replace('T', ' ')}</td>
|
|
519
|
+
</tr>`).join('')}
|
|
520
|
+
</tbody>
|
|
521
|
+
</table>`}</div>
|
|
522
|
+
|
|
523
|
+
<h2>Quick start</h2>
|
|
524
|
+
<p style="margin-bottom: 12px;">Paste this into your AI agent's chat to start an audit interview:</p>
|
|
525
|
+
<div class="copy-block">
|
|
526
|
+
<pre id="qs-chat" style="white-space: pre-wrap; line-height: 1.6;">I need you to complete a security access review for this project. Make sequential API calls to the Heron endpoint until it says "Interview complete".
|
|
527
|
+
|
|
528
|
+
Step 1 — Start the interview:
|
|
529
|
+
POST ${escapeHtml(baseUrl)}/v1/chat/completions
|
|
530
|
+
Body: {"model":"any","messages":[{"role":"user","content":"Hi, I am ready to answer questions about this project."}]}
|
|
531
|
+
|
|
532
|
+
The response will contain a question and a session ID (in the text and in the "heron_session_id" JSON field).
|
|
533
|
+
|
|
534
|
+
Step 2 — Send each answer:
|
|
535
|
+
POST ${escapeHtml(baseUrl)}/v1/chat/completions
|
|
536
|
+
Body: {"model":"any","messages":[{"role":"user","content":"YOUR ANSWER HERE"}],"heron_session_id":"SESSION_ID_FROM_STEP_1"}
|
|
537
|
+
|
|
538
|
+
Step 3 — Repeat step 2 with each new answer until the response says "Interview complete".
|
|
539
|
+
|
|
540
|
+
Important: answer about THIS specific project — what you actually do, what systems you connect to, what data you handle. Not general capabilities. Never reveal actual secret values — just describe credential types.</pre>
|
|
541
|
+
<button class="copy-btn" onclick="copyBlock('qs-chat')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
|
542
|
+
</div>
|
|
543
|
+
|
|
544
|
+
<p style="margin: 16px 0 8px 0;"><strong>Or</strong> point your agent's base URL at Heron:</p>
|
|
545
|
+
<div class="copy-block">
|
|
546
|
+
<pre id="qs-env" style="white-space: pre-wrap; word-break: break-all;">OPENAI_BASE_URL=${baseUrl}/v1 your-agent start</pre>
|
|
547
|
+
<button class="copy-btn" onclick="copyBlock('qs-env')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
<h2>API</h2>
|
|
551
|
+
<table>
|
|
552
|
+
<tbody>
|
|
553
|
+
<tr><td><code>POST /v1/chat/completions</code></td><td>OpenAI-compatible — agents connect here</td></tr>
|
|
554
|
+
<tr><td><code>GET /api/sessions</code></td><td>List all sessions (JSON)</td></tr>
|
|
555
|
+
<tr><td><code>GET /api/sessions/:id</code></td><td>Session details + transcript</td></tr>
|
|
556
|
+
<tr><td><code>GET /api/sessions/:id/report</code></td><td>Download audit report (markdown)</td></tr>
|
|
557
|
+
</tbody>
|
|
558
|
+
</table>
|
|
559
|
+
|
|
560
|
+
<div class="footer">Powered by <a href="https://github.com/jonydony/Heron">Heron</a> — open-source agent checkpoint</div>
|
|
561
|
+
<script>
|
|
562
|
+
function copyBlock(id) {
|
|
563
|
+
var el = document.getElementById(id);
|
|
564
|
+
if (!el) return;
|
|
565
|
+
var copyIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
566
|
+
var checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
567
|
+
navigator.clipboard.writeText(el.textContent).then(function() {
|
|
568
|
+
var btn = el.parentElement.querySelector('.copy-btn');
|
|
569
|
+
if (btn) { btn.innerHTML = checkIcon; btn.classList.add('copied'); setTimeout(function() { btn.innerHTML = copyIcon; btn.classList.remove('copied'); }, 2000); }
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
(function() {
|
|
573
|
+
var table = document.getElementById('sessions-table');
|
|
574
|
+
var countEl = document.getElementById('session-count');
|
|
575
|
+
if (!table) return;
|
|
576
|
+
var polling = setInterval(function() {
|
|
577
|
+
fetch('/api/sessions').then(function(r) { return r.json(); }).then(function(data) {
|
|
578
|
+
var sessions = data.sessions;
|
|
579
|
+
if (!sessions) return;
|
|
580
|
+
countEl.textContent = sessions.length;
|
|
581
|
+
if (!sessions.length) return;
|
|
582
|
+
var hasActive = sessions.some(function(s) { return s.status === 'interviewing' || s.status === 'analyzing'; });
|
|
583
|
+
var tbody = table.querySelector('tbody');
|
|
584
|
+
if (!tbody) {
|
|
585
|
+
table.innerHTML = '<table><thead><tr><th>Session</th><th>Status</th><th>Questions</th><th>Risk</th><th>Started</th></tr></thead><tbody></tbody></table>';
|
|
586
|
+
tbody = table.querySelector('tbody');
|
|
587
|
+
}
|
|
588
|
+
sessions.forEach(function(s) {
|
|
589
|
+
var row = tbody.querySelector('tr[data-id="' + s.id + '"]');
|
|
590
|
+
if (!row) {
|
|
591
|
+
row = document.createElement('tr');
|
|
592
|
+
row.setAttribute('data-id', s.id);
|
|
593
|
+
row.innerHTML = '<td><a href="/sessions/' + s.id + '"><code>' + s.id + '</code></a></td><td></td><td></td><td></td><td></td>';
|
|
594
|
+
tbody.insertBefore(row, tbody.firstChild);
|
|
595
|
+
}
|
|
596
|
+
var cells = row.querySelectorAll('td');
|
|
597
|
+
cells[1].innerHTML = '<span class="badge badge-' + s.status + '">' + s.status + '</span>';
|
|
598
|
+
cells[2].textContent = s.questionsAsked;
|
|
599
|
+
cells[3].innerHTML = s.riskLevel ? '<span class="risk risk-' + s.riskLevel + '">' + s.riskLevel.toUpperCase() + '</span>' : '\\u2014';
|
|
600
|
+
cells[4].textContent = s.createdAt.slice(0,19).replace('T',' ');
|
|
601
|
+
});
|
|
602
|
+
if (!hasActive) clearInterval(polling);
|
|
603
|
+
}).catch(function() {});
|
|
604
|
+
}, 3000);
|
|
605
|
+
})();
|
|
606
|
+
</script>
|
|
607
|
+
</body>
|
|
608
|
+
</html>`;
|
|
609
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'X-Content-Type-Options': 'nosniff' });
|
|
610
|
+
res.end(html);
|
|
611
|
+
}
|
|
612
|
+
function escapeHtml(s) {
|
|
613
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
614
|
+
}
|
|
615
|
+
// ─── Utilities ────────────────────────────────────────────────────────────
|
|
616
|
+
function json(res, status, data) {
|
|
617
|
+
res.writeHead(status, {
|
|
618
|
+
'Content-Type': 'application/json',
|
|
619
|
+
'X-Content-Type-Options': 'nosniff',
|
|
620
|
+
});
|
|
621
|
+
res.end(JSON.stringify(data));
|
|
622
|
+
}
|
|
623
|
+
/** Maximum request body size: 8KB (Decision #20 — 8K response cap for both directions) */
|
|
624
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
625
|
+
async function readBody(req) {
|
|
626
|
+
return new Promise((resolve, reject) => {
|
|
627
|
+
const chunks = [];
|
|
628
|
+
let totalSize = 0;
|
|
629
|
+
req.on('data', (chunk) => {
|
|
630
|
+
totalSize += chunk.length;
|
|
631
|
+
if (totalSize > MAX_BODY_BYTES) {
|
|
632
|
+
req.destroy();
|
|
633
|
+
reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} byte limit`));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
chunks.push(chunk);
|
|
637
|
+
});
|
|
638
|
+
req.on('end', () => {
|
|
639
|
+
try {
|
|
640
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
641
|
+
resolve(JSON.parse(raw));
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
reject(new Error('Invalid JSON body'));
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
req.on('error', reject);
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=index.js.map
|