@vale-dinap/lucidly 0.1.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.
@@ -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.kind === 'command' && text) {
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
- this.run.status = 'working';
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
- this.setStatus(cur.id, 'active', ts);
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;
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vale-dinap/lucidly",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "See what your AI agent is doing, at a glance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -46,6 +46,7 @@
46
46
  "bin/",
47
47
  "dist/",
48
48
  "web/",
49
- "NOTICE"
49
+ "NOTICE",
50
+ "CHANGELOG.md"
50
51
  ]
51
52
  }