@vale-dinap/lucidly 0.2.0 → 0.3.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/CHANGELOG.md +59 -26
- package/README.md +49 -13
- package/dist/adapters/claude-code.js +38 -5
- package/dist/adapters/claude-tools.js +73 -0
- package/dist/adapters/shell-writes.js +506 -0
- package/dist/cli.js +19 -8
- package/dist/core/engine.js +93 -6
- package/dist/core/text.js +13 -0
- package/dist/server/server.js +1 -2
- package/dist/sources/claude-sessions.js +13 -0
- package/package.json +1 -1
- package/web/app.js +4406 -3411
- package/web/index.html +11 -3
- package/web/style.css +1185 -973
- package/web/theme.js +9 -0
package/dist/core/engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const MAX_PATCH_LINES = 2000;
|
|
2
2
|
const zeroUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, responses: 0 });
|
|
3
|
-
import { extractPlan, firstLine, hasDeviationCue, hasIntent, intentTitle, isSubstantivePrompt, isReply, endsWithQuestion, issueSummary, matchStep, sentences, similarity, stripMarkdown, tokens, truncate, } from "./text.js";
|
|
3
|
+
import { extractPlan, firstLine, hasDeviationCue, hasIntent, intentTitle, isSubstantivePrompt, isHousekeeping, isReply, endsWithQuestion, lastQuestion, issueSummary, matchStep, sentences, similarity, stripMarkdown, tokens, truncate, } from "./text.js";
|
|
4
4
|
const MAX_CHUNK = 256 * 1024;
|
|
5
5
|
const MAX_CONTEXT_ITEMS = 12;
|
|
6
6
|
/**
|
|
@@ -17,6 +17,8 @@ export class Engine {
|
|
|
17
17
|
seq = 0;
|
|
18
18
|
/** The main agent's last words asked the user something. */
|
|
19
19
|
agentAsked = false;
|
|
20
|
+
/** ...and the question itself. */
|
|
21
|
+
agentQuestion;
|
|
20
22
|
goalCtx = new Map();
|
|
21
23
|
units = new Map();
|
|
22
24
|
calls = new Map();
|
|
@@ -159,7 +161,9 @@ export class Engine {
|
|
|
159
161
|
row.diff = n.diff;
|
|
160
162
|
if (n.files?.length && (n.kind === 'edit' || n.kind === 'write'))
|
|
161
163
|
row.files = n.files;
|
|
162
|
-
if (n.
|
|
164
|
+
if (n.via)
|
|
165
|
+
row.via = n.via;
|
|
166
|
+
if ((n.kind === 'command' || n.via === 'command') && text) {
|
|
163
167
|
row.command = text.replace(/^(?:\$|PS>)\s?/, '');
|
|
164
168
|
if (n.detail)
|
|
165
169
|
row.short = n.detail.replace(/^\$\s?/, '');
|
|
@@ -429,7 +433,10 @@ export class Engine {
|
|
|
429
433
|
}
|
|
430
434
|
// A message sent while the agent is still in its turn is an interjection, not a new task.
|
|
431
435
|
const midTurn = this.run.status === 'working';
|
|
432
|
-
|
|
436
|
+
// /compact, /model...: no turn follows them, so the session (and its task) stays as it was.
|
|
437
|
+
const housekeeping = isHousekeeping(text);
|
|
438
|
+
if (!housekeeping)
|
|
439
|
+
this.run.status = 'working';
|
|
433
440
|
const cur = this.currentGoal();
|
|
434
441
|
// After the turn, a short message continues the task only if it answers the agent
|
|
435
442
|
// ("yes", "go ahead", or anything after a question); "all done, pushed" is a new card.
|
|
@@ -440,18 +447,84 @@ export class Engine {
|
|
|
440
447
|
this.addChunk(g.id, 'prompt', raw ?? text, ts, 'Prompt');
|
|
441
448
|
return;
|
|
442
449
|
}
|
|
443
|
-
|
|
450
|
+
if (!midTurn && !housekeeping)
|
|
451
|
+
return this.startRound(cur, text, ts, raw);
|
|
452
|
+
if (!housekeeping)
|
|
453
|
+
return this.interject(cur, text, ts, raw);
|
|
454
|
+
// A housekeeping command (/compact...) is a note where the task stands, not a message to the agent.
|
|
444
455
|
const target = cur.unit ?? cur.current ?? cur.id;
|
|
445
456
|
const n = this.newNode('activity', target, `You: ${truncate(text, 80)}`, { kind: 'user', status: 'done', startedAt: ts });
|
|
446
457
|
this.addChunk(n.id, 'prompt', raw ?? text, ts, 'User reply');
|
|
447
458
|
cur.lastTextChunk = null;
|
|
448
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* A reply that continues a task after its turn ended starts a new round of it. The reply sits
|
|
462
|
+
* on the task itself, after the steps so far (and before a plan's steps still to come), keeping
|
|
463
|
+
* how the round before it ended; the work that follows goes into a step of its own.
|
|
464
|
+
*/
|
|
465
|
+
startRound(g, text, ts, raw) {
|
|
466
|
+
const goal = this.touch(g.id);
|
|
467
|
+
const round = {
|
|
468
|
+
...(goal.outcome ? { outcome: goal.outcome } : {}),
|
|
469
|
+
...(g.interrupted ? { interrupted: true } : {}),
|
|
470
|
+
...(this.agentAsked ? { asked: true } : {}),
|
|
471
|
+
};
|
|
472
|
+
this.closeSubtask(g, ts);
|
|
473
|
+
// The next action picks the next unfinished plan step, or starts a new step (see onToolStart).
|
|
474
|
+
g.current = null;
|
|
475
|
+
g.unit = null;
|
|
476
|
+
g.pending = null;
|
|
477
|
+
g.lastTextChunk = null;
|
|
478
|
+
g.interrupted = false;
|
|
479
|
+
this.userNode(g, text, ts, raw, { round });
|
|
480
|
+
delete goal.outcome;
|
|
481
|
+
delete goal.asked;
|
|
482
|
+
delete goal.question;
|
|
483
|
+
this.setStatus(g.id, 'active', ts);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* A message sent while the agent works: like every message of yours, a node of its own on the
|
|
487
|
+
* task, after the steps so far. The work that follows goes into a new step, except that a plan
|
|
488
|
+
* step under way stays the current one until the plan moves on.
|
|
489
|
+
*/
|
|
490
|
+
interject(g, text, ts, raw) {
|
|
491
|
+
this.closeSubtask(g, ts);
|
|
492
|
+
const step = g.current ? this.nodes.get(g.current) : undefined;
|
|
493
|
+
if (step && step.origin !== 'plan') {
|
|
494
|
+
if (step.status === 'active')
|
|
495
|
+
this.setStatus(step.id, 'done', ts);
|
|
496
|
+
g.current = null;
|
|
497
|
+
g.unit = null;
|
|
498
|
+
}
|
|
499
|
+
g.pending = null;
|
|
500
|
+
g.lastTextChunk = null;
|
|
501
|
+
this.userNode(g, text, ts, raw, {});
|
|
502
|
+
this.setStatus(g.id, 'active', ts);
|
|
503
|
+
}
|
|
504
|
+
/** A message of yours on the task itself: after the steps so far, before a plan's steps not started. */
|
|
505
|
+
userNode(g, text, ts, raw, extra) {
|
|
506
|
+
const goal = this.touch(g.id);
|
|
507
|
+
const n = this.newNode('activity', g.id, `You: ${truncate(text, 80)}`, { kind: 'user', status: 'done', startedAt: ts, ...extra });
|
|
508
|
+
const rest = goal.children.filter((c) => c !== n.id);
|
|
509
|
+
let at = rest.length;
|
|
510
|
+
while (at > 0) {
|
|
511
|
+
const c = this.nodes.get(rest[at - 1]);
|
|
512
|
+
if (!(c?.type === 'step' && (c.status === 'pending' || c.tentative)))
|
|
513
|
+
break;
|
|
514
|
+
at--;
|
|
515
|
+
}
|
|
516
|
+
rest.splice(at, 0, n.id);
|
|
517
|
+
goal.children = rest;
|
|
518
|
+
this.addChunk(n.id, 'prompt', raw ?? text, ts, 'User reply');
|
|
519
|
+
}
|
|
449
520
|
// ───────────────────────────── narration
|
|
450
521
|
onText(text, ts, scope) {
|
|
451
522
|
if (!text.trim())
|
|
452
523
|
return;
|
|
453
|
-
if (!scope)
|
|
524
|
+
if (!scope) {
|
|
454
525
|
this.agentAsked = endsWithQuestion(text);
|
|
526
|
+
this.agentQuestion = this.agentAsked ? lastQuestion(text) : undefined;
|
|
527
|
+
}
|
|
455
528
|
if (scope) {
|
|
456
529
|
const n = this.scopes.get(scope);
|
|
457
530
|
if (n) {
|
|
@@ -854,6 +927,7 @@ export class Engine {
|
|
|
854
927
|
tool: ev.tool,
|
|
855
928
|
detail: ev.detail,
|
|
856
929
|
files: ev.files,
|
|
930
|
+
...(ev.via ? { via: ev.via } : {}),
|
|
857
931
|
status: 'active',
|
|
858
932
|
startedAt: ev.ts,
|
|
859
933
|
});
|
|
@@ -862,7 +936,9 @@ export class Engine {
|
|
|
862
936
|
if (ev.spawnsAgent)
|
|
863
937
|
this.scopes.set(ev.callId, node.id);
|
|
864
938
|
this.calls.set(ev.callId, { node: node.id, unit, tool: ev.tool, label: ev.label });
|
|
865
|
-
this.addChunk(node.id, 'tool', ev.raw ?? ev.detail ?? ev.label, ev.ts, ev.label);
|
|
939
|
+
const chunk = this.addChunk(node.id, 'tool', ev.raw ?? ev.detail ?? ev.label, ev.ts, ev.label);
|
|
940
|
+
if (ev.parts?.length && ev.parts.every((p) => p.text.length <= MAX_CHUNK))
|
|
941
|
+
this.chunks[chunk].parts = ev.parts;
|
|
866
942
|
this.bump(unit, 'actions');
|
|
867
943
|
this.addContext(unit, ev);
|
|
868
944
|
const held = ev.spawnsAgent ? this.orphans.get(ev.callId) : undefined;
|
|
@@ -1023,6 +1099,17 @@ export class Engine {
|
|
|
1023
1099
|
if (g.plan.length === 0 || isLastPlanned || !g.plan.includes(g.current))
|
|
1024
1100
|
this.setStatus(g.current, 'done', ts);
|
|
1025
1101
|
}
|
|
1102
|
+
if (this.agentAsked && !interrupted) {
|
|
1103
|
+
goal.asked = true;
|
|
1104
|
+
if (this.agentQuestion)
|
|
1105
|
+
goal.question = this.agentQuestion;
|
|
1106
|
+
else
|
|
1107
|
+
delete goal.question;
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
delete goal.asked;
|
|
1111
|
+
delete goal.question;
|
|
1112
|
+
}
|
|
1026
1113
|
// The agent finished its turn: the task is done, unless it was cut short. A follow-up
|
|
1027
1114
|
// reply reopens it (see onPrompt).
|
|
1028
1115
|
this.setStatus(g.id, interrupted ? 'waiting' : 'done', ts);
|
package/dist/core/text.js
CHANGED
|
@@ -132,6 +132,10 @@ export function extractPlan(text, requireCue = false) {
|
|
|
132
132
|
const cue = PLAN_CUE.test(lead);
|
|
133
133
|
if ((!best.numbered || requireCue) && !cue)
|
|
134
134
|
return null;
|
|
135
|
+
// Bullets that all open with a bold label ("**Rows:** the spotlight...") describe things, they
|
|
136
|
+
// aren't steps to take.
|
|
137
|
+
if (!best.numbered && best.items.every((s) => /^\*\*[^*]{1,60}?(?::\*\*|\*\*:)/.test(s.trim())))
|
|
138
|
+
return null;
|
|
135
139
|
const items = best.items.map(cleanItem).filter((s) => s.length > 1);
|
|
136
140
|
if (items.length < 2)
|
|
137
141
|
return null;
|
|
@@ -200,6 +204,10 @@ const HOUSEKEEPING = /^\/(compact|clear|model|config|cost|context|status|help|me
|
|
|
200
204
|
* A short message that answers the agent rather than giving it something new: an approval,
|
|
201
205
|
* a choice, or anything at all when the agent's last words asked a question.
|
|
202
206
|
*/
|
|
207
|
+
/** A slash command that manages the session (/compact, /model...) rather than asking for anything. */
|
|
208
|
+
export function isHousekeeping(text) {
|
|
209
|
+
return HOUSEKEEPING.test(text.trim());
|
|
210
|
+
}
|
|
203
211
|
export function isReply(text, agentAsked) {
|
|
204
212
|
const t = text.trim();
|
|
205
213
|
// Housekeeping commands never start a task; any other slash command is a new request.
|
|
@@ -215,6 +223,11 @@ export function isReply(text, agentAsked) {
|
|
|
215
223
|
export function endsWithQuestion(text) {
|
|
216
224
|
return /\?\s*(\*+|_+|\)|")?\s*$/.test(text.trim());
|
|
217
225
|
}
|
|
226
|
+
/** The question the agent's words end with: their last sentence, without its formatting. */
|
|
227
|
+
export function lastQuestion(text) {
|
|
228
|
+
const q = sentences(text).at(-1);
|
|
229
|
+
return q && /\?\W*$/.test(q) ? truncate(q.replace(/(?<=\?)\W*$/, ''), 200) : undefined;
|
|
230
|
+
}
|
|
218
231
|
export function isSubstantivePrompt(text) {
|
|
219
232
|
const t = text.trim();
|
|
220
233
|
const words = t.split(/\s+/).filter(Boolean).length;
|
package/dist/server/server.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readFile, readdir, stat } from 'node:fs/promises';
|
|
|
8
8
|
import { extname, normalize, resolve, sep } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { RunSession } from "./session.js";
|
|
11
|
-
import { ClaudeSessionSource, findSession, listSessions } from "../sources/claude-sessions.js";
|
|
11
|
+
import { ClaudeSessionSource, LIVE_MS, findSession, listSessions } from "../sources/claude-sessions.js";
|
|
12
12
|
const WEB_DIR = fileURLToPath(new URL('../../web/', import.meta.url));
|
|
13
13
|
const SRC_DIR = fileURLToPath(new URL('../', import.meta.url));
|
|
14
14
|
const STARTED = Date.now();
|
|
@@ -33,7 +33,6 @@ async function codeChangedSince(t) {
|
|
|
33
33
|
return false;
|
|
34
34
|
}
|
|
35
35
|
const MAX_BODY = 512 * 1024 * 1024;
|
|
36
|
-
const LIVE_MS = 3 * 60 * 1000;
|
|
37
36
|
const TYPES = {
|
|
38
37
|
'.html': 'text/html; charset=utf-8',
|
|
39
38
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -15,6 +15,8 @@ export function projectsDir() {
|
|
|
15
15
|
export function encodeCwd(cwd) {
|
|
16
16
|
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
17
17
|
}
|
|
18
|
+
/** A session written to within this long counts as live (the agent is, or was just, at work). */
|
|
19
|
+
export const LIVE_MS = 3 * 60 * 1000;
|
|
18
20
|
async function readSlice(path, start, length) {
|
|
19
21
|
const fh = await open(path, 'r');
|
|
20
22
|
try {
|
|
@@ -26,9 +28,20 @@ async function readSlice(path, start, length) {
|
|
|
26
28
|
await fh.close();
|
|
27
29
|
}
|
|
28
30
|
}
|
|
31
|
+
// Descriptions by path, reused while the file is unchanged: the session list refreshes every few
|
|
32
|
+
// seconds while it is open, and only the sessions written to since are read again.
|
|
33
|
+
const described = new Map();
|
|
29
34
|
/** Cheap metadata: read the head for cwd/first prompt and the tail for the latest title. */
|
|
30
35
|
export async function describeSession(path) {
|
|
31
36
|
const st = await stat(path);
|
|
37
|
+
const known = described.get(path);
|
|
38
|
+
if (known && known.mtime === st.mtimeMs && known.size === st.size)
|
|
39
|
+
return known;
|
|
40
|
+
const info = await readDescription(path, st);
|
|
41
|
+
described.set(path, info);
|
|
42
|
+
return info;
|
|
43
|
+
}
|
|
44
|
+
async function readDescription(path, st) {
|
|
32
45
|
const info = {
|
|
33
46
|
id: basename(path, '.jsonl'),
|
|
34
47
|
path,
|