@vale-dinap/lucidly 0.0.0-stage → 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 +202 -0
- package/NOTICE +2 -0
- package/README.md +114 -2
- package/bin/lucidly.js +5 -0
- package/dist/adapters/claude-code.js +367 -0
- package/dist/adapters/claude-tools.js +123 -0
- package/dist/adapters/text.js +106 -0
- package/dist/cli.js +215 -0
- package/dist/core/engine.js +1034 -0
- package/dist/core/events.js +5 -0
- package/dist/core/model.js +1 -0
- package/dist/core/text.js +253 -0
- package/dist/outline.js +73 -0
- package/dist/server/server.js +286 -0
- package/dist/server/session.js +101 -0
- package/dist/sources/claude-sessions.js +329 -0
- package/dist/sources/source.js +1 -0
- package/dist/sources/stdin.js +74 -0
- package/dist/sources/tail.js +105 -0
- package/package.json +49 -4
- package/web/app.js +3317 -0
- package/web/favicon.svg +9 -0
- package/web/index.html +114 -0
- package/web/style.css +950 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/** Text heuristics shared by the engine: intent titles, plan extraction, step matching. */
|
|
2
|
+
export function truncate(s, max) {
|
|
3
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
4
|
+
if (t.length <= max)
|
|
5
|
+
return t;
|
|
6
|
+
const cut = t.slice(0, max - 1);
|
|
7
|
+
const sp = cut.lastIndexOf(' ');
|
|
8
|
+
return (sp > max * 0.6 ? cut.slice(0, sp) : cut).replace(/[\s,;:.-]+$/, '') + '…';
|
|
9
|
+
}
|
|
10
|
+
export function stripMarkdown(s) {
|
|
11
|
+
return s
|
|
12
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
13
|
+
.replace(/[*_`~]+/g, '')
|
|
14
|
+
.replace(/^#+\s*/, '')
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
function capitalize(s) {
|
|
18
|
+
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
|
19
|
+
}
|
|
20
|
+
const INTENT_CUE = /\b(?:let me|let's|i'll|i will|i'm going to|i am going to|i need to|i'm now going to|next,? i'll|now i'll|time to)\s+/i;
|
|
21
|
+
/** Split prose into sentences, ignoring code blocks, headings and list items. */
|
|
22
|
+
export function sentences(text) {
|
|
23
|
+
const noCode = text.replace(/```[\s\S]*?```/g, ' ');
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of noCode.split(/\n+/)) {
|
|
26
|
+
const l = line.trim();
|
|
27
|
+
if (!l || /^(#|\||[-*+]\s|\d+[.)]\s)/.test(l))
|
|
28
|
+
continue;
|
|
29
|
+
for (const s of l.split(/(?<=[.!?])\s+(?=[A-Z"'(`*])/)) {
|
|
30
|
+
const c = stripMarkdown(s).trim();
|
|
31
|
+
if (c)
|
|
32
|
+
out.push(c);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Derive a short step title from narration, favouring the sentence that states intent:
|
|
39
|
+
* "Good. Now let me run the tests." → "Run the tests".
|
|
40
|
+
*/
|
|
41
|
+
export function intentTitle(text, max = 90) {
|
|
42
|
+
const ss = sentences(text);
|
|
43
|
+
if (ss.length === 0)
|
|
44
|
+
return truncate(stripMarkdown(text), max) || 'Working';
|
|
45
|
+
for (const s of ss) {
|
|
46
|
+
const m = INTENT_CUE.exec(s);
|
|
47
|
+
if (m) {
|
|
48
|
+
const rest = s
|
|
49
|
+
.slice(m.index + m[0].length)
|
|
50
|
+
.replace(/^(?:now|first|also|then|quickly|just)\s+/i, '')
|
|
51
|
+
.replace(/^(?:start|begin) (?:by|with)\s+/i, '')
|
|
52
|
+
.replace(/[.:!]+$/, '');
|
|
53
|
+
if (rest.length > 3)
|
|
54
|
+
return truncate(capitalize(rest), max);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// A terse opener ("All pass.") followed by more text: the next sentence says more.
|
|
58
|
+
const pick = ss.length > 1 && ss[0].split(/\s+/).length <= 3 ? ss[1] : ss[0];
|
|
59
|
+
return truncate(pick.replace(/[.:]+$/, ''), max);
|
|
60
|
+
}
|
|
61
|
+
/** True when the text carries an explicit intent statement. */
|
|
62
|
+
export function hasIntent(text) {
|
|
63
|
+
return sentences(text).some((s) => INTENT_CUE.test(s));
|
|
64
|
+
}
|
|
65
|
+
const PLAN_CUE = /\b(plan|steps?|order of work|approach|roadmap|todo|to-do|phases?|i'll|i will|going to|let me|let's|first|then|next)\b/i;
|
|
66
|
+
function cleanItem(s) {
|
|
67
|
+
let t = stripMarkdown(s);
|
|
68
|
+
if (t.length > 60) {
|
|
69
|
+
const m = /^(.{6,60}?)(?::\s| — | – | - )/.exec(t);
|
|
70
|
+
if (m)
|
|
71
|
+
t = m[1];
|
|
72
|
+
}
|
|
73
|
+
return truncate(t.replace(/[.;:]+$/, ''), 90);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Find a plan-shaped list in narration. Numbered lists (1., 2., ...) qualify on their own;
|
|
77
|
+
* bulleted lists need a planning cue in the line(s) right before them.
|
|
78
|
+
* With `requireCue`, numbered lists also need the cue (used for re-planning mid-goal).
|
|
79
|
+
*/
|
|
80
|
+
export function extractPlan(text, requireCue = false) {
|
|
81
|
+
const lines = text.replace(/```[\s\S]*?```/g, '').split('\n');
|
|
82
|
+
let best = null;
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < lines.length) {
|
|
85
|
+
const num = /^\s{0,3}(\d{1,2})[.)]\s+(.+)$/.exec(lines[i]);
|
|
86
|
+
const bul = /^\s{0,1}[-*+]\s+(.+)$/.exec(lines[i]);
|
|
87
|
+
if (!num && !bul) {
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const numbered = !!num;
|
|
92
|
+
const start = i;
|
|
93
|
+
const items = [];
|
|
94
|
+
let expect = 1;
|
|
95
|
+
while (i < lines.length) {
|
|
96
|
+
const l = lines[i];
|
|
97
|
+
if (numbered) {
|
|
98
|
+
const m = /^\s{0,3}(\d{1,2})[.)]\s+(.+)$/.exec(l);
|
|
99
|
+
if (m && Number(m[1]) === expect) {
|
|
100
|
+
items.push(m[2]);
|
|
101
|
+
expect++;
|
|
102
|
+
i++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const m = /^\s{0,1}[-*+]\s+(.+)$/.exec(l);
|
|
108
|
+
if (m) {
|
|
109
|
+
items.push(m[1]);
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// allow indented continuation / sub-items and blank lines inside the list
|
|
115
|
+
if (/^\s{2,}\S/.test(l) || (l.trim() === '' && i + 1 < lines.length && /^\s{0,3}(\d{1,2}[.)]|[-*+])\s/.test(lines[i + 1]))) {
|
|
116
|
+
i++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (i === start)
|
|
122
|
+
i++;
|
|
123
|
+
if (numbered && items.length > 0 && !/^\s{0,3}1[.)]/.test(lines[start]))
|
|
124
|
+
items.length = 0;
|
|
125
|
+
if (items.length >= 2 && (!best || items.length > best.items.length))
|
|
126
|
+
best = { start, items, numbered };
|
|
127
|
+
}
|
|
128
|
+
if (!best)
|
|
129
|
+
return null;
|
|
130
|
+
const before = lines.slice(0, best.start).filter((l) => l.trim());
|
|
131
|
+
const lead = before.slice(-2).join(' ');
|
|
132
|
+
const cue = PLAN_CUE.test(lead);
|
|
133
|
+
if ((!best.numbered || requireCue) && !cue)
|
|
134
|
+
return null;
|
|
135
|
+
const items = best.items.map(cleanItem).filter((s) => s.length > 1);
|
|
136
|
+
if (items.length < 2)
|
|
137
|
+
return null;
|
|
138
|
+
if (best.items.some((s) => s.length > 400))
|
|
139
|
+
return null;
|
|
140
|
+
return { intro: stripMarkdown(lead), items };
|
|
141
|
+
}
|
|
142
|
+
const STOP = new Set('the and for with that this from into then than them they have has had was were are will would should could can let lets now next also just some more most very what when where which while who why how all any each its it’s it\'s our you your not but out use using about over under after before again first second third step steps i\'ll done make sure need needs check'.split(' '));
|
|
143
|
+
function stem(w) {
|
|
144
|
+
return w.replace(/(?:ing|ed|es|s)$/, '') || w;
|
|
145
|
+
}
|
|
146
|
+
export function tokens(s) {
|
|
147
|
+
const out = new Set();
|
|
148
|
+
for (const w of s.toLowerCase().match(/[a-z0-9][a-z0-9_-]*/g) ?? []) {
|
|
149
|
+
if (w.length < 3 || STOP.has(w))
|
|
150
|
+
continue;
|
|
151
|
+
out.add(stem(w));
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
export function similarity(a, b) {
|
|
156
|
+
if (a.size === 0 || b.size === 0)
|
|
157
|
+
return 0;
|
|
158
|
+
let common = 0;
|
|
159
|
+
for (const t of a)
|
|
160
|
+
if (b.has(t))
|
|
161
|
+
common++;
|
|
162
|
+
return common / Math.sqrt(a.size * b.size);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Pick which plan step a narration refers to.
|
|
166
|
+
* Returns an index into `steps`, or -1 when nothing matches well enough.
|
|
167
|
+
*/
|
|
168
|
+
export function matchStep(text, steps, current) {
|
|
169
|
+
const explicit = /\b(?:step|phase|part)\s+#?(\d{1,2})\b/i.exec(text);
|
|
170
|
+
if (explicit) {
|
|
171
|
+
const idx = Number(explicit[1]) - 1;
|
|
172
|
+
if (idx >= 0 && idx < steps.length)
|
|
173
|
+
return idx;
|
|
174
|
+
}
|
|
175
|
+
// Use the intent sentence(s) rather than the whole message: long messages match everything.
|
|
176
|
+
const focus = sentences(text).slice(0, 3).join(' ');
|
|
177
|
+
const t = tokens(focus || text);
|
|
178
|
+
let best = -1;
|
|
179
|
+
let bestScore = 0;
|
|
180
|
+
const from = Math.max(0, current);
|
|
181
|
+
for (let i = from; i < steps.length; i++) {
|
|
182
|
+
if (steps[i].done && i !== current)
|
|
183
|
+
continue;
|
|
184
|
+
const score = similarity(t, tokens(steps[i].title)) - 0.04 * Math.max(0, i - from - 1);
|
|
185
|
+
if (score > bestScore) {
|
|
186
|
+
bestScore = score;
|
|
187
|
+
best = i;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return bestScore >= 0.3 ? best : -1;
|
|
191
|
+
}
|
|
192
|
+
const DEVIATION = /\b(fix|fixing|fixed|retry|retrying|try again|instead|workaround|missing|failed|failing|fails|error|errors|broken|doesn't work|didn't work|not found|unexpected|turns out|wrong|bug|investigate|debug|debugging|revert)\b/i;
|
|
193
|
+
export function hasDeviationCue(text) {
|
|
194
|
+
return DEVIATION.test(sentences(text).slice(0, 2).join(' ') || text);
|
|
195
|
+
}
|
|
196
|
+
/** Is this user message the start of a new task, or a short reply within the current one? */
|
|
197
|
+
const REPLY = /^(yes|yep|yeah|yup|y|no|nope|ok|okay|k|sure|go|go ahead|go on|do it|do so|proceed|continue|carry on|keep going|please do|please|sounds good|looks good|lgtm|agreed|right|correct|exactly|perfect|great|thanks|thank you|both|neither|either|the (first|second|third|last) one|option \w+|\d+)\b/i;
|
|
198
|
+
const HOUSEKEEPING = /^\/(compact|clear|model|config|cost|context|status|help|memory|permissions|login|logout|doctor|resume|fast|effort|theme|usage|statusline)\b/i;
|
|
199
|
+
/**
|
|
200
|
+
* A short message that answers the agent rather than giving it something new: an approval,
|
|
201
|
+
* a choice, or anything at all when the agent's last words asked a question.
|
|
202
|
+
*/
|
|
203
|
+
export function isReply(text, agentAsked) {
|
|
204
|
+
const t = text.trim();
|
|
205
|
+
// Housekeeping commands never start a task; any other slash command is a new request.
|
|
206
|
+
if (HOUSEKEEPING.test(t))
|
|
207
|
+
return true;
|
|
208
|
+
if (t.startsWith('/'))
|
|
209
|
+
return false;
|
|
210
|
+
if (isSubstantivePrompt(t))
|
|
211
|
+
return false;
|
|
212
|
+
return agentAsked || REPLY.test(text.trim());
|
|
213
|
+
}
|
|
214
|
+
/** The agent's words end by asking the user something. */
|
|
215
|
+
export function endsWithQuestion(text) {
|
|
216
|
+
return /\?\s*(\*+|_+|\)|")?\s*$/.test(text.trim());
|
|
217
|
+
}
|
|
218
|
+
export function isSubstantivePrompt(text) {
|
|
219
|
+
const t = text.trim();
|
|
220
|
+
const words = t.split(/\s+/).filter(Boolean).length;
|
|
221
|
+
return words >= 6 || t.length >= 40;
|
|
222
|
+
}
|
|
223
|
+
const ERROR_LINE = /\b(error|exception|failed|failure|fatal|denied|not found|cannot|can't|unable|invalid|refused|timed? ?out|traceback|panic|undefined|missing)\b|✗|✖|FAIL\b/i;
|
|
224
|
+
/**
|
|
225
|
+
* One-line summary of a failure. Prefers the most telling line (a Python traceback's last line,
|
|
226
|
+
* an "error:" line) over boilerplate such as "Exit code 1", and keeps the exit code as a prefix.
|
|
227
|
+
*/
|
|
228
|
+
export function issueSummary(output, max = 160) {
|
|
229
|
+
const lines = output.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
230
|
+
if (!lines.length)
|
|
231
|
+
return 'Failed';
|
|
232
|
+
let exit = '';
|
|
233
|
+
const m = /^exit code (\d+)$/i.exec(lines[0]);
|
|
234
|
+
if (m) {
|
|
235
|
+
const hint = { '124': 'timed out', '126': 'not executable', '127': 'command not found', '130': 'interrupted', '137': 'killed', '143': 'terminated' };
|
|
236
|
+
exit = `exit ${m[1]}${hint[m[1]] ? ` (${hint[m[1]]})` : ''}`;
|
|
237
|
+
lines.shift();
|
|
238
|
+
}
|
|
239
|
+
if (!lines.length)
|
|
240
|
+
return exit || 'Failed';
|
|
241
|
+
const tracebackEnd = lines.some((l) => /^Traceback \(most recent call last\)/.test(l))
|
|
242
|
+
? lines.slice().reverse().find((l) => /^[A-Za-z_.]+(Error|Exception|Exit|Interrupt)\b/.test(l))
|
|
243
|
+
: undefined;
|
|
244
|
+
// No error-looking line: output usually ends with the failure, not starts with it.
|
|
245
|
+
const pick = tracebackEnd ??
|
|
246
|
+
lines.find((l) => ERROR_LINE.test(l) && !/^at\s/.test(l) && !/^Traceback \(most recent/.test(l)) ??
|
|
247
|
+
lines[lines.length - 1];
|
|
248
|
+
return truncate(exit ? `${exit} · ${stripMarkdown(pick)}` : pick, max);
|
|
249
|
+
}
|
|
250
|
+
export function firstLine(s, max = 160) {
|
|
251
|
+
const l = s.split('\n').find((x) => x.trim()) ?? '';
|
|
252
|
+
return truncate(l, max);
|
|
253
|
+
}
|
package/dist/outline.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function filterActions(e, rows, o) {
|
|
2
|
+
const goalIds = o.goals ? new Set(e.run.goals.slice(-o.goals)) : null;
|
|
3
|
+
const q = o.grep?.toLowerCase();
|
|
4
|
+
return rows.filter((r) => (!o.kinds?.length || o.kinds.includes(r.kind)) &&
|
|
5
|
+
(!o.errorsOnly || r.status === 'failed') &&
|
|
6
|
+
(!goalIds || (r.goalId && goalIds.has(r.goalId))) &&
|
|
7
|
+
(!q || [r.title, r.command, r.step, r.input].some((s) => s?.toLowerCase().includes(q))));
|
|
8
|
+
}
|
|
9
|
+
const time = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour12: false }) : '--:--:--');
|
|
10
|
+
/** Chronological action list for terminals: one line per action, grouped under its task. */
|
|
11
|
+
export function actionList(e, o = {}) {
|
|
12
|
+
const rows = filterActions(e, e.actions(o.full ? 1_000_000 : 4000), o);
|
|
13
|
+
const out = [];
|
|
14
|
+
let goal;
|
|
15
|
+
for (const r of rows) {
|
|
16
|
+
if (r.goalId !== goal) {
|
|
17
|
+
goal = r.goalId;
|
|
18
|
+
out.push(`\n■ ${r.goal ?? 'Session'}`);
|
|
19
|
+
}
|
|
20
|
+
const mark = r.status === 'failed' ? '✖' : r.status === 'active' ? '▶' : '✔';
|
|
21
|
+
const what = r.command !== undefined ? `$ ${o.full ? r.command : (r.short ?? firstLineOf(r.command))}` : r.title;
|
|
22
|
+
const where = [r.subagent ? `subagent: ${r.subagent}` : '', r.step ?? ''].filter(Boolean).join(' › ');
|
|
23
|
+
out.push(`${time(r.startedAt)} ${mark} ${what}${r.retry ? ' (retry)' : ''}${where ? ` [${where}]` : ''}`);
|
|
24
|
+
if (r.status === 'failed' && r.issue)
|
|
25
|
+
out.push(` └ ${r.issue}${r.recovered ? ' (recovered)' : ''}`);
|
|
26
|
+
}
|
|
27
|
+
const failed = rows.filter((r) => r.status === 'failed').length;
|
|
28
|
+
const recovered = rows.filter((r) => r.status === 'failed' && r.recovered).length;
|
|
29
|
+
out.push(`\n${rows.length} actions${failed - recovered ? `, ${failed - recovered} failed` : ''}${recovered ? `, ${recovered} recovered after failing` : ''}`);
|
|
30
|
+
return out.join('\n').replace(/^\n/, '');
|
|
31
|
+
}
|
|
32
|
+
function firstLineOf(s) {
|
|
33
|
+
const lines = s.split('\n');
|
|
34
|
+
return lines[0] + (lines.length > 1 ? ' …' : '');
|
|
35
|
+
}
|
|
36
|
+
const ICON = {
|
|
37
|
+
done: '✔',
|
|
38
|
+
active: '▶',
|
|
39
|
+
pending: '○',
|
|
40
|
+
failed: '✖',
|
|
41
|
+
open: '⛔',
|
|
42
|
+
resolved: '✓',
|
|
43
|
+
waiting: '⏸',
|
|
44
|
+
};
|
|
45
|
+
export function outline(e, opts = {}) {
|
|
46
|
+
const out = [];
|
|
47
|
+
const r = e.run;
|
|
48
|
+
out.push(`${r.title ?? 'Run'} — ${r.agent ?? 'agent'} · ${r.status} · ${r.goals.length} goals · ${r.eventCount} events`);
|
|
49
|
+
const show = (id, depth) => {
|
|
50
|
+
const n = e.nodes.get(id);
|
|
51
|
+
if (!n)
|
|
52
|
+
return;
|
|
53
|
+
const pad = ' '.repeat(depth);
|
|
54
|
+
if (n.type === 'activity') {
|
|
55
|
+
if (opts.activities)
|
|
56
|
+
out.push(`${pad}${ICON[n.status] ?? '·'} ${n.title}${n.retry ? ' (retry)' : ''}${n.issue ? ` — ${n.issue}` : ''}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const stats = n.stats && n.type !== 'goal' ? ` [${n.stats.actions} actions${n.stats.errors ? `, ${n.stats.errors} errors` : ''}]` : '';
|
|
60
|
+
const origin = n.origin && n.origin !== 'inferred' && n.origin !== 'plan' ? ` (${n.origin})` : '';
|
|
61
|
+
out.push(`${pad}${ICON[n.status] ?? '·'} ${n.type === 'goal' ? 'GOAL ' : n.type === 'step' ? '' : n.type + ': '}${n.title}${origin}${stats}`);
|
|
62
|
+
if (n.type === 'goal' && n.intent)
|
|
63
|
+
out.push(`${pad} intent: ${n.intent}`);
|
|
64
|
+
for (const c of n.children)
|
|
65
|
+
show(c, depth + 1);
|
|
66
|
+
if (n.type === 'goal' && n.outcome)
|
|
67
|
+
out.push(`${pad} outcome: ${n.outcome}`);
|
|
68
|
+
};
|
|
69
|
+
const goals = opts.goals ? r.goals.slice(-opts.goals) : r.goals;
|
|
70
|
+
for (const g of goals)
|
|
71
|
+
show(g, 0);
|
|
72
|
+
return out.join('\n');
|
|
73
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP server: static web UI, JSON API, and Server-Sent Events for live updates.
|
|
3
|
+
* Binds to 127.0.0.1 only and rejects foreign Host headers (DNS rebinding) and
|
|
4
|
+
* cross-site writes (POST bodies must be JSON, which forces a CORS preflight we never answer).
|
|
5
|
+
*/
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
8
|
+
import { extname, normalize, resolve, sep } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { RunSession } from "./session.js";
|
|
11
|
+
import { ClaudeSessionSource, findSession, listSessions } from "../sources/claude-sessions.js";
|
|
12
|
+
const WEB_DIR = fileURLToPath(new URL('../../web/', import.meta.url));
|
|
13
|
+
const SRC_DIR = fileURLToPath(new URL('../', import.meta.url));
|
|
14
|
+
const STARTED = Date.now();
|
|
15
|
+
/**
|
|
16
|
+
* Whether the server's own code changed on disk since it started. The web UI is read from
|
|
17
|
+
* disk on every request, so a running server can serve a newer page than its engine knows
|
|
18
|
+
* about: the page says so, rather than quietly missing data.
|
|
19
|
+
*/
|
|
20
|
+
async function codeChangedSince(t) {
|
|
21
|
+
try {
|
|
22
|
+
const files = await readdir(SRC_DIR, { recursive: true });
|
|
23
|
+
for (const f of files) {
|
|
24
|
+
if (!String(f).endsWith('.ts'))
|
|
25
|
+
continue;
|
|
26
|
+
if ((await stat(resolve(SRC_DIR, String(f)))).mtimeMs > t)
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Installed without sources nearby: nothing to compare.
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const MAX_BODY = 512 * 1024 * 1024;
|
|
36
|
+
const LIVE_MS = 3 * 60 * 1000;
|
|
37
|
+
const TYPES = {
|
|
38
|
+
'.html': 'text/html; charset=utf-8',
|
|
39
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
40
|
+
'.css': 'text/css; charset=utf-8',
|
|
41
|
+
'.svg': 'image/svg+xml',
|
|
42
|
+
'.json': 'application/json; charset=utf-8',
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.ico': 'image/x-icon',
|
|
45
|
+
};
|
|
46
|
+
export class LucidlyServer {
|
|
47
|
+
runs = new Map();
|
|
48
|
+
byKey = new Map();
|
|
49
|
+
seq = 0;
|
|
50
|
+
server = null;
|
|
51
|
+
port = 0;
|
|
52
|
+
cwd;
|
|
53
|
+
wantPort;
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
this.wantPort = opts.port;
|
|
56
|
+
this.cwd = opts.cwd;
|
|
57
|
+
}
|
|
58
|
+
get url() {
|
|
59
|
+
return `http://127.0.0.1:${this.port}/`;
|
|
60
|
+
}
|
|
61
|
+
/** Listen on the requested port, or the next free one. */
|
|
62
|
+
async listen() {
|
|
63
|
+
for (let p = this.wantPort; p < this.wantPort + 20; p++) {
|
|
64
|
+
try {
|
|
65
|
+
await new Promise((ok, fail) => {
|
|
66
|
+
const s = createServer((req, res) => void this.handle(req, res));
|
|
67
|
+
s.once('error', fail);
|
|
68
|
+
s.listen(p, '127.0.0.1', () => {
|
|
69
|
+
s.off('error', fail);
|
|
70
|
+
this.server = s;
|
|
71
|
+
this.port = s.address().port;
|
|
72
|
+
ok();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (e?.code !== 'EADDRINUSE' || this.wantPort === 0)
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`No free port from ${this.wantPort}`);
|
|
83
|
+
}
|
|
84
|
+
async close() {
|
|
85
|
+
for (const r of this.runs.values())
|
|
86
|
+
r.close();
|
|
87
|
+
this.server?.closeAllConnections?.();
|
|
88
|
+
await new Promise((ok) => (this.server ? this.server.close(() => ok()) : ok()));
|
|
89
|
+
}
|
|
90
|
+
/** Open (or reuse) a run for a source. */
|
|
91
|
+
async open(source) {
|
|
92
|
+
const existing = this.byKey.get(source.key);
|
|
93
|
+
if (existing && this.runs.has(existing))
|
|
94
|
+
return this.runs.get(existing);
|
|
95
|
+
const run = new RunSession(`r${++this.seq}`, source);
|
|
96
|
+
this.runs.set(run.id, run);
|
|
97
|
+
this.byKey.set(run.key, run.id);
|
|
98
|
+
await run.start();
|
|
99
|
+
return run;
|
|
100
|
+
}
|
|
101
|
+
addRun(make) {
|
|
102
|
+
const run = make(`r${++this.seq}`);
|
|
103
|
+
this.runs.set(run.id, run);
|
|
104
|
+
this.byKey.set(run.key, run.id);
|
|
105
|
+
return run;
|
|
106
|
+
}
|
|
107
|
+
closeRun(id) {
|
|
108
|
+
const r = this.runs.get(id);
|
|
109
|
+
if (!r)
|
|
110
|
+
return false;
|
|
111
|
+
r.close();
|
|
112
|
+
this.runs.delete(id);
|
|
113
|
+
this.byKey.delete(r.key);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
// ───────────────────────────── HTTP
|
|
117
|
+
hostOk(req) {
|
|
118
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
119
|
+
return [`127.0.0.1:${this.port}`, `localhost:${this.port}`, `[::1]:${this.port}`].includes(host);
|
|
120
|
+
}
|
|
121
|
+
async handle(req, res) {
|
|
122
|
+
try {
|
|
123
|
+
if (!this.hostOk(req))
|
|
124
|
+
return send(res, 403, { error: 'forbidden host' });
|
|
125
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
126
|
+
const path = url.pathname;
|
|
127
|
+
if (req.method === 'POST' || req.method === 'DELETE') {
|
|
128
|
+
const origin = req.headers.origin;
|
|
129
|
+
if (origin && origin !== `http://${req.headers.host}`)
|
|
130
|
+
return send(res, 403, { error: 'forbidden origin' });
|
|
131
|
+
if (req.method === 'POST' && !String(req.headers['content-type'] ?? '').startsWith('application/json')) {
|
|
132
|
+
return send(res, 415, { error: 'JSON body required' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (path.startsWith('/api/'))
|
|
136
|
+
return await this.api(req, res, path, url);
|
|
137
|
+
if (req.method !== 'GET')
|
|
138
|
+
return send(res, 405, { error: 'method not allowed' });
|
|
139
|
+
return await this.static(res, path);
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (!res.headersSent)
|
|
143
|
+
send(res, 500, { error: String(e?.message ?? e) });
|
|
144
|
+
else
|
|
145
|
+
res.end();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async api(req, res, path, url) {
|
|
149
|
+
const m = /^\/api\/s\/([^/]+)(?:\/([a-z]+)(?:\/([^/]+))?)?$/.exec(path);
|
|
150
|
+
if (m) {
|
|
151
|
+
const run = this.runs.get(m[1]);
|
|
152
|
+
if (!run)
|
|
153
|
+
return send(res, 404, { error: 'no such run' });
|
|
154
|
+
const [, , action, arg] = m;
|
|
155
|
+
if (!action && req.method === 'DELETE')
|
|
156
|
+
return send(res, 200, { closed: this.closeRun(run.id) });
|
|
157
|
+
if (action === 'stream')
|
|
158
|
+
return this.stream(req, res, run);
|
|
159
|
+
if (action === 'raw' && arg) {
|
|
160
|
+
if (!run.engine.nodes.has(arg))
|
|
161
|
+
return send(res, 404, { error: 'no such node' });
|
|
162
|
+
return send(res, 200, { node: arg, chunks: run.engine.raw(arg, url.searchParams.get('own') === '1') });
|
|
163
|
+
}
|
|
164
|
+
if (action === 'actions') {
|
|
165
|
+
const cap = Math.min(Number(url.searchParams.get('cap')) || 1200, 20000);
|
|
166
|
+
return send(res, 200, { actions: run.engine.actions(cap) });
|
|
167
|
+
}
|
|
168
|
+
if (action === 'save') {
|
|
169
|
+
const name = slug(run.engine.run.title ?? run.label) + '.lucidly.json';
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
'content-type': 'application/json; charset=utf-8',
|
|
172
|
+
'content-disposition': `attachment; filename="${name}"`,
|
|
173
|
+
'cache-control': 'no-store',
|
|
174
|
+
});
|
|
175
|
+
res.end(JSON.stringify(run.save()));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
return send(res, 404, { error: 'unknown action' });
|
|
179
|
+
}
|
|
180
|
+
if (path === '/api/health' && req.method === 'GET') {
|
|
181
|
+
return send(res, 200, { startedAt: new Date(STARTED).toISOString(), outdated: await codeChangedSince(STARTED) });
|
|
182
|
+
}
|
|
183
|
+
if (path === '/api/runs' && req.method === 'GET') {
|
|
184
|
+
return send(res, 200, { runs: [...this.runs.values()].map((r) => r.info()) });
|
|
185
|
+
}
|
|
186
|
+
if (path === '/api/sessions' && req.method === 'GET') {
|
|
187
|
+
const all = url.searchParams.get('scope') === 'all';
|
|
188
|
+
const sessions = await listSessions({ cwd: all ? undefined : this.cwd, limit: all ? 60 : 30 });
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
return send(res, 200, {
|
|
191
|
+
cwd: this.cwd,
|
|
192
|
+
scope: all ? 'all' : 'project',
|
|
193
|
+
sessions: sessions.map((s) => ({
|
|
194
|
+
...s,
|
|
195
|
+
live: now - s.mtime < LIVE_MS,
|
|
196
|
+
run: this.byKey.get(new ClaudeSessionSource(s.path).key),
|
|
197
|
+
})),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (path === '/api/open' && req.method === 'POST') {
|
|
201
|
+
const body = (await readJson(req));
|
|
202
|
+
const target = body.path ?? (body.id ? await findSession(body.id) : null);
|
|
203
|
+
if (!target || !target.endsWith('.jsonl'))
|
|
204
|
+
return send(res, 400, { error: 'session not found' });
|
|
205
|
+
try {
|
|
206
|
+
await stat(target);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return send(res, 404, { error: 'session file not found' });
|
|
210
|
+
}
|
|
211
|
+
const run = await this.open(new ClaudeSessionSource(target));
|
|
212
|
+
return send(res, 200, { run: run.info() });
|
|
213
|
+
}
|
|
214
|
+
if (path === '/api/load' && req.method === 'POST') {
|
|
215
|
+
const data = await readJson(req);
|
|
216
|
+
try {
|
|
217
|
+
const run = this.addRun((id) => RunSession.fromSave(id, data));
|
|
218
|
+
return send(res, 200, { run: run.info() });
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
return send(res, 400, { error: String(e?.message ?? e) });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return send(res, 404, { error: 'not found' });
|
|
225
|
+
}
|
|
226
|
+
stream(req, res, run) {
|
|
227
|
+
res.writeHead(200, {
|
|
228
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
229
|
+
'cache-control': 'no-store',
|
|
230
|
+
connection: 'keep-alive',
|
|
231
|
+
'x-accel-buffering': 'no',
|
|
232
|
+
});
|
|
233
|
+
res.write('retry: 2000\n\n');
|
|
234
|
+
const unsubscribe = run.subscribe((event, data) => {
|
|
235
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
236
|
+
});
|
|
237
|
+
const ping = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
238
|
+
req.on('close', () => {
|
|
239
|
+
clearInterval(ping);
|
|
240
|
+
unsubscribe();
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async static(res, path) {
|
|
244
|
+
const rel = path === '/' ? 'index.html' : decodeURIComponent(path).replace(/^\/+/, '');
|
|
245
|
+
const full = resolve(WEB_DIR, normalize(rel));
|
|
246
|
+
if (!full.startsWith(resolve(WEB_DIR) + sep) && full !== resolve(WEB_DIR))
|
|
247
|
+
return send(res, 403, { error: 'forbidden' });
|
|
248
|
+
let body;
|
|
249
|
+
try {
|
|
250
|
+
body = await readFile(full);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return send(res, 404, { error: 'not found' });
|
|
254
|
+
}
|
|
255
|
+
res.writeHead(200, {
|
|
256
|
+
'content-type': TYPES[extname(full)] ?? 'application/octet-stream',
|
|
257
|
+
'cache-control': 'no-cache',
|
|
258
|
+
'x-content-type-options': 'nosniff',
|
|
259
|
+
'content-security-policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
|
|
260
|
+
});
|
|
261
|
+
res.end(body);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function send(res, status, body) {
|
|
265
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
266
|
+
res.end(JSON.stringify(body));
|
|
267
|
+
}
|
|
268
|
+
async function readJson(req) {
|
|
269
|
+
const chunks = [];
|
|
270
|
+
let size = 0;
|
|
271
|
+
for await (const c of req) {
|
|
272
|
+
size += c.length;
|
|
273
|
+
if (size > MAX_BODY)
|
|
274
|
+
throw new Error('body too large');
|
|
275
|
+
chunks.push(c);
|
|
276
|
+
}
|
|
277
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
278
|
+
}
|
|
279
|
+
function slug(s) {
|
|
280
|
+
return (s
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
283
|
+
.replace(/^-+|-+$/g, '')
|
|
284
|
+
.slice(0, 60) || 'run');
|
|
285
|
+
}
|
|
286
|
+
export { WEB_DIR };
|