@adhdev/daemon-core 0.9.82-rc.220 → 0.9.82-rc.221

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,23 +1,16 @@
1
1
  /**
2
- * Spec evaluator — pure function.
2
+ * Section + condition evaluator — pure functions shared by the v4 FSM engine.
3
3
  *
4
- * Given a visible screen text and a CliSpec (v3), returns:
5
- * - which sections cover which line ranges
6
- * - which state matched and why
7
- * - extracted modal title + buttons (if any)
8
- * - which control_bar entries are visible
9
- * - which notifications/delegates this evaluation activates
10
- * - a trace object that explains every decision (for the inspector)
11
- *
12
- * No I/O, no state. Pass prevLines for delta (changed) condition support.
4
+ * Provides: section resolution, condition evaluation (regex/changed/all/any),
5
+ * title/button extraction. The v4 FSM driver and evaluator import these
6
+ * directly; there is no v3 SpecDriver anymore.
13
7
  */
14
8
  'use strict';
15
9
 
16
10
  import type {
17
- CliSpec, SectionDef, SpecStateV3, Condition, RegexCondition,
11
+ SectionDef, Condition, RegexCondition,
18
12
  ChangedCondition, AllCondition, AnyCondition,
19
13
  ExtractTitle, ExtractButtons,
20
- ControlAction, NotificationRule, DelegateTrigger,
21
14
  } from './types.js';
22
15
 
23
16
  export interface ResolvedSection {
@@ -27,45 +20,13 @@ export interface ResolvedSection {
27
20
  text: string;
28
21
  }
29
22
 
30
- export interface ModalSnapshot {
31
- title: string | null;
32
- buttons: { index: number; label: string; key: string }[];
33
- }
34
-
35
- export interface VisibleControl {
36
- id: string;
37
- label: string;
38
- actionType: 'send_keys' | 'open_picker' | 'attach_image';
39
- }
40
-
41
- export interface FiredNotification {
42
- id: string;
43
- title: string;
44
- body: string;
45
- }
46
-
47
- export interface FiredDelegate {
48
- id: string;
49
- task: string;
50
- }
51
-
52
23
  export interface TraceEntry {
53
24
  kind: 'section' | 'state_match' | 'state_skip' | 'modal' | 'control' | 'notification' | 'delegate';
54
25
  text: string;
55
26
  }
56
27
 
57
- export interface SpecEvaluation {
58
- state: { id: string; label: string; title: string | null };
59
- modal: ModalSnapshot | null;
60
- controls: VisibleControl[];
61
- notifications: FiredNotification[];
62
- delegates: FiredDelegate[];
63
- sections: ResolvedSection[];
64
- trace: TraceEntry[];
65
- }
66
-
67
28
  // ────────────────────────────────────────────────────────────────────────────
68
- // Layout — v3 sections{} object
29
+ // Layout — sections{} object
69
30
  // ────────────────────────────────────────────────────────────────────────────
70
31
 
71
32
  function resolveSize(size: number | string | undefined, total: number): number {
@@ -77,10 +38,6 @@ function resolveSize(size: number | string | undefined, total: number): number {
77
38
  return Math.max(0, Math.min(total, Math.round((total * pct) / 100)));
78
39
  }
79
40
 
80
- /**
81
- * Resolve v3 sections{} object into an ordered array of ResolvedSection.
82
- * Two-pass: first anchor/positional, then apply `until` cross-references.
83
- */
84
41
  export function resolveSections(
85
42
  sectionsObj: Record<string, SectionDef>,
86
43
  lines: string[],
@@ -114,7 +71,6 @@ export function resolveSections(
114
71
  from = idx;
115
72
  to = total;
116
73
  if (sec.until_regex !== undefined) {
117
- // until_regex on anchor-based sections (extension field)
118
74
  try {
119
75
  const ure = new RegExp(sec.until_regex, sec.until_regex_flags ?? '');
120
76
  const end = lines.findIndex((l, i) => i > idx && ure.test(l));
@@ -123,8 +79,6 @@ export function resolveSections(
123
79
  } else if (sec.lines !== undefined) {
124
80
  to = Math.min(total, from + sec.lines);
125
81
  }
126
- // `until` regex check on anchor sections (starts with ^)
127
- // handled in second pass
128
82
  }
129
83
  } catch { /* bad anchor regex */ }
130
84
  } else if (sec.from_top !== undefined) {
@@ -146,14 +100,12 @@ export function resolveSections(
146
100
 
147
101
  if (sec.until !== undefined) {
148
102
  if (sec.until.startsWith('^')) {
149
- // `until` is a regex: stop at the first matching line after fromLine
150
103
  try {
151
104
  const ure = new RegExp(sec.until);
152
105
  const end = lines.findIndex((l, i) => i > fromLine && ure.test(l));
153
106
  if (end !== -1) toLine = end;
154
107
  } catch { /* bad until regex */ }
155
108
  } else {
156
- // `until` is a section id reference
157
109
  const target = anchored.get(sec.until);
158
110
  if (target) toLine = target.fromLine;
159
111
  }
@@ -174,7 +126,7 @@ export function sectionText(sections: ResolvedSection[], sectionId: string | und
174
126
  }
175
127
 
176
128
  // ────────────────────────────────────────────────────────────────────────────
177
- // Condition evaluation (v3)
129
+ // Condition evaluation
178
130
  // ────────────────────────────────────────────────────────────────────────────
179
131
 
180
132
  function isRegexCondition(c: Condition): c is RegexCondition {
@@ -224,12 +176,10 @@ export function evaluateCondition(
224
176
  if (!cursor || !prevLines || prevLines.length === 0) return false;
225
177
  const curLines = fullScreen.split('\n');
226
178
  const startRow = Math.max(0, cursor.row - cond.cursor_above);
227
- const endRow = cursor.row; // exclusive
179
+ const endRow = cursor.row;
228
180
  const currentSlice = curLines.slice(startRow, endRow).join('\n');
229
181
  const prevSlice = prevLines.slice(startRow, endRow).join('\n');
230
182
  const didChange = currentSlice !== prevSlice;
231
- // changed:false means "region is currently stable" — stable_ms duration
232
- // is enforced by the driver, not here.
233
183
  const result = cond.changed ? didChange : !didChange;
234
184
  const stableSuffix = cond.stable_ms != null ? ` stable_ms=${cond.stable_ms}` : '';
235
185
  trace.push({
@@ -252,7 +202,6 @@ export function evaluateCondition(
252
202
  return false;
253
203
  }
254
204
 
255
- // Cursor-position guards
256
205
  if (cursor !== undefined) {
257
206
  if (cond.cursor_row_min !== undefined && cursor.row < cond.cursor_row_min) {
258
207
  trace.push({ kind: 'state_skip', text: `state[${stateId}] cursor row ${cursor.row} < cursor_row_min ${cond.cursor_row_min}` });
@@ -280,47 +229,9 @@ export function evaluateCondition(
280
229
  }
281
230
 
282
231
  // ────────────────────────────────────────────────────────────────────────────
283
- // State matching
232
+ // Extraction helpers (used by FsmDriver for modal/title)
284
233
  // ────────────────────────────────────────────────────────────────────────────
285
234
 
286
- function matchState(
287
- state: SpecStateV3,
288
- sections: ResolvedSection[],
289
- fullScreen: string,
290
- trace: TraceEntry[],
291
- cursor: { row: number; col: number } | undefined,
292
- prevLines: string[] | undefined,
293
- ): { matched: boolean; title: string | null } {
294
- // Support v1-shaped state.when ({ regex, section }) for backward compat
295
- // with test objects that don't go through the loader.
296
- let effectiveWhen = state.when;
297
- const stateAny = state as any;
298
- if (stateAny.when && 'regex' in stateAny.when && !('all' in stateAny.when) && !('any' in stateAny.when)) {
299
- effectiveWhen = normalizeV1When(stateAny.when);
300
- }
301
- const condMatched = evaluateCondition(effectiveWhen, sections, fullScreen, cursor, prevLines, trace, state.id);
302
-
303
- if (!condMatched) {
304
- trace.push({ kind: 'state_skip', text: `state[${state.id}] when condition not met` });
305
- return { matched: false, title: null };
306
- }
307
-
308
- trace.push({ kind: 'state_match', text: `state[${state.id}] matched` });
309
-
310
- let title: string | null = null;
311
- const stateAny2 = state as any;
312
- const extract = state.extract;
313
- // v1 compat: extract_title
314
- const titleRule: ExtractTitle | undefined = extract?.title
315
- ?? (stateAny2.extract_title ? v1ExtractTitleToV3(stateAny2.extract_title) : undefined);
316
- if (titleRule) {
317
- title = extractTitle(titleRule, sections, fullScreen);
318
- trace.push({ kind: 'state_match', text: `state[${state.id}] extract.title → ${title ?? '(none)'}` });
319
- }
320
-
321
- return { matched: true, title };
322
- }
323
-
324
235
  export function extractTitle(
325
236
  rule: ExtractTitle,
326
237
  sections: ResolvedSection[],
@@ -330,7 +241,6 @@ export function extractTitle(
330
241
  if (!hay) return null;
331
242
 
332
243
  if (rule.first_line) {
333
- // Take the first non-separator, non-empty line
334
244
  const lines = hay.split('\n');
335
245
  for (const line of lines) {
336
246
  const stripped = line.trim();
@@ -409,196 +319,3 @@ export function extractButtonsFromRule(
409
319
  buttons.sort((a, b) => a.index - b.index);
410
320
  return buttons;
411
321
  }
412
-
413
- function extractModal(
414
- state: SpecStateV3,
415
- sections: ResolvedSection[],
416
- fullScreen: string,
417
- title: string | null,
418
- trace: TraceEntry[],
419
- ): ModalSnapshot | null {
420
- const stateAny = state as any;
421
- // v1 compat: modal_buttons
422
- const buttonsRule: ExtractButtons | undefined = state.extract?.buttons
423
- ?? (stateAny.modal_buttons ? v1ModalButtonsToV3(stateAny.modal_buttons) : undefined);
424
- if (!buttonsRule) return null;
425
-
426
- const hay = sectionText(sections, buttonsRule.section, fullScreen);
427
- const minCount = buttonsRule.min_count ?? 2;
428
- const buttons = extractButtonsFromRule(buttonsRule, hay);
429
-
430
- if (buttons.length < minCount) {
431
- trace.push({ kind: 'modal', text: `extract.buttons matched ${buttons.length}/${minCount} required — discarded` });
432
- return null;
433
- }
434
- trace.push({ kind: 'modal', text: `extract.buttons matched ${buttons.length} choices` });
435
- return { title, buttons };
436
- }
437
-
438
- // ────────────────────────────────────────────────────────────────────────────
439
- // v1 backward-compat helpers
440
- // ────────────────────────────────────────────────────────────────────────────
441
-
442
- /**
443
- * Convert a v1 sections array to a v3 sections object (for inline test specs
444
- * that bypass the loader and thus the migration path).
445
- */
446
- function buildSectionsMapFromV1(v1Sections: any[]): Record<string, SectionDef> {
447
- const map: Record<string, SectionDef> = {};
448
- for (const sec of v1Sections) {
449
- const { id, anchor_regex, ...rest } = sec;
450
- const def: SectionDef = { ...rest };
451
- if (anchor_regex) def.anchor = anchor_regex;
452
- // v1 until: { section: id } → v3 until: id
453
- if (rest.until?.section) def.until = rest.until.section;
454
- else if (rest.until && typeof rest.until === 'string') def.until = rest.until;
455
- else delete (def as any).until;
456
- map[id] = def;
457
- }
458
- return map;
459
- }
460
-
461
- /** Convert v1 SectionRegex (when.regex) to v3 AllCondition. */
462
- function normalizeV1When(v1When: any): AllCondition {
463
- if (v1When?.cursor_above_lines && v1When?.changed) {
464
- return { all: [{ cursor_above: v1When.cursor_above_lines, changed: true as const }] };
465
- }
466
- const cond: any = { matches: v1When.regex };
467
- if (v1When.section) cond.section = v1When.section;
468
- if (v1When.flags) cond.flags = v1When.flags;
469
- if (v1When.cursor_row_min !== undefined) cond.cursor_row_min = v1When.cursor_row_min;
470
- if (v1When.cursor_row_max !== undefined) cond.cursor_row_max = v1When.cursor_row_max;
471
- if (v1When.cursor_col_min !== undefined) cond.cursor_col_min = v1When.cursor_col_min;
472
- if (v1When.cursor_col_max !== undefined) cond.cursor_col_max = v1When.cursor_col_max;
473
- return { all: [cond] };
474
- }
475
-
476
- /** Convert v1 extract_title to v3 ExtractTitle. */
477
- function v1ExtractTitleToV3(v1: any): ExtractTitle {
478
- if (v1.first_line) return { section: v1.section, first_line: true };
479
- return { section: v1.section, regex: v1.regex, flags: v1.flags };
480
- }
481
-
482
- /** Convert v1 modal_buttons to v3 ExtractButtons. */
483
- function v1ModalButtonsToV3(v1: any): ExtractButtons | undefined {
484
- // Use first pattern from patterns[] or single pattern
485
- const pat = v1.patterns?.length ? v1.patterns[0].pattern : v1.pattern;
486
- if (!pat) return undefined;
487
- const flg = v1.patterns?.length ? v1.patterns[0].flags : v1.flags;
488
- return {
489
- section: v1.section,
490
- pattern: pat,
491
- flags: flg,
492
- key_for_index: v1.key_for_index,
493
- min_count: v1.min_count,
494
- continuation_lines: v1.continuation_lines,
495
- };
496
- }
497
-
498
- // ────────────────────────────────────────────────────────────────────────────
499
- // Public evaluator
500
- // ────────────────────────────────────────────────────────────────────────────
501
-
502
- export function evaluate(
503
- spec: CliSpec,
504
- screenText: string,
505
- /** Optional cursor position (0-based row and col). */
506
- cursor?: { row: number; col: number },
507
- /** Optional previous screen lines for `changed` condition detection. */
508
- prevLines?: string[],
509
- ): SpecEvaluation {
510
- const trace: TraceEntry[] = [];
511
- const lines = screenText.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
512
- const cleanScreen = lines.join('\n');
513
-
514
- // Support both v3 (sections{}) and v1-shaped objects (layout.sections[])
515
- // The v1 path exists for tests that build raw spec objects without going
516
- // through the loader (which would normally migrate v1 → v3).
517
- const specAny = spec as any;
518
- const effectiveSectionsMap: Record<string, SectionDef> = spec.sections
519
- ?? buildSectionsMapFromV1(specAny.layout?.sections ?? []);
520
- const sections = resolveSections(effectiveSectionsMap, lines);
521
-
522
- for (const s of sections) {
523
- trace.push({ kind: 'section', text: `section[${s.id}] lines [${s.fromLine}, ${s.toLine}) (${s.toLine - s.fromLine} lines)` });
524
- }
525
- if (cursor !== undefined) {
526
- trace.push({ kind: 'section', text: `cursor (${cursor.row}, ${cursor.col})` });
527
- }
528
-
529
- let activeState: { id: string; label: string; title: string | null } | null = null;
530
- let modal: ModalSnapshot | null = null;
531
-
532
- for (const st of spec.states) {
533
- const { matched, title } = matchState(st, sections, cleanScreen, trace, cursor, prevLines);
534
- if (!matched) continue;
535
- const extractedModal = extractModal(st, sections, cleanScreen, title, trace);
536
- // If the state declares extract.buttons (or v1 modal_buttons) but
537
- // extraction failed, don't promote the state (avoids phantom approvals).
538
- const stAny = st as any;
539
- if ((st.extract?.buttons || stAny.modal_buttons) && !extractedModal) {
540
- trace.push({ kind: 'state_skip', text: `state[${st.id}] matched but extract.buttons failed — not promoting` });
541
- continue;
542
- }
543
- activeState = { id: st.id, label: st.label, title };
544
- modal = extractedModal;
545
- break;
546
- }
547
-
548
- if (!activeState) {
549
- const def = spec.states.find(s => s.id === spec.default_state);
550
- if (def) {
551
- activeState = { id: def.id, label: def.label, title: null };
552
- trace.push({ kind: 'state_match', text: `(no state matched — fallback to default_state ${def.id})` });
553
- } else {
554
- activeState = { id: spec.default_state, label: spec.default_state, title: null };
555
- trace.push({ kind: 'state_match', text: `(no state matched, no default_state defined — using id "${spec.default_state}")` });
556
- }
557
- }
558
-
559
- const controls: VisibleControl[] = [];
560
- for (const c of spec.control_bar ?? []) {
561
- const visible = !c.visible_when_state || c.visible_when_state.includes(activeState.id);
562
- trace.push({ kind: 'control', text: `control[${c.id}] visible=${visible}${c.visible_when_state ? ` (when_state ${c.visible_when_state.join('|')})` : ''}` });
563
- if (visible) controls.push({ id: c.id, label: c.label, actionType: c.action.type });
564
- }
565
-
566
- const notifications: FiredNotification[] = [];
567
- for (const n of spec.notifications ?? []) {
568
- if (n.when_state !== activeState.id) continue;
569
- const body = interpolate(n.body ?? '', activeState, sections);
570
- notifications.push({ id: n.id, title: n.title, body });
571
- trace.push({ kind: 'notification', text: `notification[${n.id}] fired (when_state=${n.when_state})` });
572
- }
573
-
574
- const delegates: FiredDelegate[] = [];
575
- for (const d of spec.delegate ?? []) {
576
- if (d.when_state !== activeState.id) continue;
577
- delegates.push({ id: d.id, task: interpolate(d.task_template, activeState, sections) });
578
- }
579
-
580
- return {
581
- state: activeState,
582
- modal,
583
- controls,
584
- notifications,
585
- delegates,
586
- sections,
587
- trace,
588
- };
589
- }
590
-
591
- function interpolate(
592
- template: string,
593
- state: { id: string; label: string; title: string | null },
594
- sections: ResolvedSection[],
595
- ): string {
596
- return template
597
- .replace(/\{state\.label\}/g, state.label)
598
- .replace(/\{state\.title\}/g, state.title ?? '')
599
- .replace(/\{state\.id\}/g, state.id)
600
- .replace(/\{screen\.([a-z][a-z0-9_]*)\}/g, (_, id: string) => {
601
- const s = sections.find(s => s.id === id);
602
- return s ? s.text : '';
603
- });
604
- }
@@ -26,7 +26,7 @@ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
26
26
  import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
27
27
  import {
28
28
  resolveSections, sectionText, extractTitle, extractButtonsFromRule,
29
- type ResolvedSection,
29
+ type ResolvedSection, type TraceEntry,
30
30
  } from './evaluator.js';
31
31
  import { evaluateFsm, type FsmClock, type TransitionEval } from './fsm-evaluator.js';
32
32
  import {
@@ -35,13 +35,99 @@ import {
35
35
  } from './fsm-types.js';
36
36
  import { loadFsmSpec } from './fsm-loader.js';
37
37
  import type { Control, DelegateTrigger } from './types.js';
38
- import {
39
- type DashboardEvent, type DashboardCommand, type SpecDriverOpts,
40
- type ISpecDriver, type DriverHistoryEntry,
41
- resolveSubmitDelayMs, guessExt,
42
- } from './driver.js';
43
38
  import { LOG } from '../../logging/logger.js';
44
39
 
40
+ // ── Shared driver types (formerly in driver.ts) ───────────────────────────
41
+
42
+ export type DashboardEvent =
43
+ | { kind: 'pty_data'; chunk: string }
44
+ | { kind: 'state_changed'; state: { id: string; label: string; title: string | null };
45
+ modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
46
+ controls: { id: string; label: string; action_type: string }[] }
47
+ | { kind: 'notification'; id: string; title: string; body: string }
48
+ | { kind: 'delegate'; id: string; task: string }
49
+ | { kind: 'spec_trace'; entries: TraceEntry[] }
50
+ | { kind: 'exit'; exit_code: number }
51
+ | { kind: 'spec_error'; errors: string[] };
52
+
53
+ export type DashboardCommand =
54
+ | { kind: 'send_message'; text: string }
55
+ | { kind: 'pty_write'; data: string }
56
+ | { kind: 'click_control'; control_id: string; payload?: unknown }
57
+ | { kind: 'click_modal_button'; index: number }
58
+ | { kind: 'attach_image'; blob: string; mime: string }
59
+ | { kind: 'resize'; cols: number; rows: number }
60
+ | { kind: 'cancel' }
61
+ | { kind: 'shutdown' };
62
+
63
+ export interface DriverHistoryEntry {
64
+ stateId: string;
65
+ label: string;
66
+ at: number;
67
+ durationMs: number;
68
+ reason: string;
69
+ matchedStateId?: string;
70
+ matchedRules?: string[];
71
+ debounceKind?: string;
72
+ idleHoldMs?: number;
73
+ busyHoldMs?: number;
74
+ via?: string;
75
+ }
76
+
77
+ export interface ISpecDriver {
78
+ subscribe(listener: (ev: DashboardEvent) => void): () => void;
79
+ start(): void;
80
+ dispatch(cmd: DashboardCommand): void;
81
+ snapshot(): string;
82
+ getCursorPosition(): { row: number; col: number };
83
+ getScreen(): string;
84
+ getSpecPath(): string;
85
+ shutdown(): void;
86
+ getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
87
+ getSections(): Array<{ id: string; text: string }> | null;
88
+ getLastBusyAt(): number;
89
+ hasIdleHoldPending(): boolean;
90
+ getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
91
+ getFsmDebug?(): unknown;
92
+ }
93
+
94
+ export interface SpecDriverOpts {
95
+ specPath: string;
96
+ workingDir: string;
97
+ extraEnv?: Record<string, string>;
98
+ cols?: number;
99
+ rows?: number;
100
+ hotReload?: boolean;
101
+ emitTrace?: boolean;
102
+ transportFactory?: PtyTransportFactory;
103
+ extraCliArgs?: string[];
104
+ }
105
+
106
+ function countNewlines(s: string): number {
107
+ let n = 0;
108
+ for (let i = 0; i < s.length; i += 1) if (s.charCodeAt(i) === 10) n += 1;
109
+ return n;
110
+ }
111
+
112
+ const SUBMIT_DELAY_FLOOR_MS = 200;
113
+
114
+ export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
115
+ const lines = countNewlines(text);
116
+ const linesBonus = Math.min(800, lines * 80);
117
+ const spec = typeof specBeforeSubmit === 'number' && specBeforeSubmit > 0 ? specBeforeSubmit : 0;
118
+ return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
119
+ }
120
+
121
+ export function guessExt(mime: string): string {
122
+ if (/png/i.test(mime)) return '.png';
123
+ if (/jpe?g/i.test(mime)) return '.jpg';
124
+ if (/gif/i.test(mime)) return '.gif';
125
+ if (/webp/i.test(mime)) return '.webp';
126
+ return '.bin';
127
+ }
128
+
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+
45
131
  interface ModalSnapshot {
46
132
  title: string | null;
47
133
  buttons: { index: number; label: string; key: string }[];