@compilr-dev/sdk 0.10.8 → 0.10.10

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.
@@ -40,6 +40,27 @@ export function compressBashOutput(command, stdout) {
40
40
  // curl / wget (HTTP responses)
41
41
  if (cmd.match(/^(curl|wget)\b/))
42
42
  return compressCurlOutput(stdout);
43
+ // GitHub CLI (verbose detail commands — pr/issue view, run view)
44
+ // List commands (pr list, issue list, run list) are already tabular and compact;
45
+ // pass them through unchanged.
46
+ if (cmd.match(/^gh\s+(pr|issue|run)\s+view\b/))
47
+ return compressGhView(stdout);
48
+ // Docker
49
+ // `docker logs` — cap to last N lines (logs blow up the context fast)
50
+ if (cmd.match(/^docker\s+logs\b/))
51
+ return compressLogsOutput(stdout);
52
+ // `docker pull/push/build` — strip noisy progress lines, keep summary
53
+ if (cmd.match(/^docker\s+(pull|push|build)\b/))
54
+ return compressDockerProgress(stdout);
55
+ // `docker ps`, `docker images`, `docker stats` — already tabular and compact, pass through
56
+ // kubectl
57
+ // `kubectl logs` — same as docker logs
58
+ if (cmd.match(/^kubectl\s+logs\b/))
59
+ return compressLogsOutput(stdout);
60
+ // `kubectl describe` — verbose, often deeply indented; strip noise + cap repeated events
61
+ if (cmd.match(/^kubectl\s+describe\b/))
62
+ return compressKubectlDescribe(stdout);
63
+ // `kubectl get` — already tabular and compact, pass through
43
64
  return null; // No compressor matched
44
65
  }
45
66
  // ─── Git Status ─────────────────────────────────────────────────────────────
@@ -296,6 +317,208 @@ function compressLsOutput(output) {
296
317
  }
297
318
  return result.join('\n');
298
319
  }
320
+ // ─── GitHub CLI: pr/issue/run view ──────────────────────────────────────────
321
+ /**
322
+ * Compress `gh pr view`, `gh issue view`, `gh run view` output.
323
+ *
324
+ * Strips noise that has zero signal for the agent:
325
+ * - HTML comments (often left over from PR/issue templates)
326
+ * - Markdown badge images and image-only lines
327
+ * - Empty metadata lines (`labels:`, `projects:`, `milestone:` with no value)
328
+ * - "🤖 Generated with [Claude Code]" / "Co-Authored-By: ..." footers
329
+ * (only the duplicated trailing block — preserves any in-body co-authors
330
+ * that appear before the body's last paragraph)
331
+ * - The trailing "View this pull request on GitHub: <url>" footer
332
+ * - Runs of 3+ blank lines collapsed to a single blank
333
+ *
334
+ * Pass-through: keeps title, status, body, labels with values, code blocks.
335
+ */
336
+ function compressGhView(output) {
337
+ // Drop HTML comments first (multiline regex)
338
+ const cleaned = output.replace(/<!--[\s\S]*?-->/g, '');
339
+ const lines = cleaned.split('\n');
340
+ const kept = [];
341
+ let consecutiveBlanks = 0;
342
+ for (const line of lines) {
343
+ const trimmed = line.trim();
344
+ // Trailing "View this pull request on GitHub: ..." / "View this issue on GitHub: ..."
345
+ if (/^View this (pull request|issue|workflow run) on GitHub:/i.test(trimmed))
346
+ continue;
347
+ // Empty metadata lines: `labels:`, `projects:`, `milestone:`, `assignees:`,
348
+ // `reviewers:` with no value after the colon.
349
+ if (/^(labels|projects|milestone|assignees|reviewers|tags):\s*$/i.test(trimmed)) {
350
+ continue;
351
+ }
352
+ // Badge image / image-only markdown lines
353
+ if (/^\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)$/.test(trimmed))
354
+ continue; // [![badge](img)](link)
355
+ if (/^!\[[^\]]*\]\([^)]*\)$/.test(trimmed))
356
+ continue; // ![alt](img)
357
+ // Collapse runs of blank lines (max 1 consecutive)
358
+ if (trimmed === '') {
359
+ consecutiveBlanks++;
360
+ if (consecutiveBlanks > 1)
361
+ continue;
362
+ }
363
+ else {
364
+ consecutiveBlanks = 0;
365
+ }
366
+ kept.push(line);
367
+ }
368
+ // Strip a trailing "🤖 Generated with [Claude Code]" / "Co-Authored-By:" footer
369
+ // if it's the last non-blank block. We only strip if it's clearly the tail —
370
+ // we don't want to lose co-authors that appear inside the body.
371
+ while (kept.length > 0) {
372
+ const last = kept[kept.length - 1].trim();
373
+ if (last === '') {
374
+ kept.pop();
375
+ continue;
376
+ }
377
+ if (last.startsWith('🤖 Generated with [Claude Code]') || /^Co-Authored-By:/i.test(last)) {
378
+ kept.pop();
379
+ continue;
380
+ }
381
+ break;
382
+ }
383
+ return kept.join('\n');
384
+ }
385
+ // ─── Container logs (docker logs / kubectl logs) ────────────────────────────
386
+ const LOGS_MAX_LINES = 100;
387
+ /**
388
+ * Cap container logs to the last N lines. Logs typically blow up token
389
+ * usage fast (a single container can produce thousands of lines), and the
390
+ * agent almost always wants the tail (most-recent failure / state).
391
+ *
392
+ * Drops a single banner line at the top noting how many lines were truncated
393
+ * so the agent knows the output was clipped.
394
+ */
395
+ function compressLogsOutput(output) {
396
+ const lines = output.split('\n');
397
+ // Don't compress small outputs — the cap is the only thing this compressor does.
398
+ if (lines.length <= LOGS_MAX_LINES)
399
+ return output;
400
+ const truncated = lines.length - LOGS_MAX_LINES;
401
+ const tail = lines.slice(-LOGS_MAX_LINES);
402
+ return `... (${String(truncated)} earlier log lines truncated — showing last ${String(LOGS_MAX_LINES)})\n${tail.join('\n')}`;
403
+ }
404
+ // ─── docker pull / push / build (strip progress noise) ──────────────────────
405
+ /**
406
+ * Strip per-layer progress lines from `docker pull/push/build`. Keeps the
407
+ * digest / "Status: Image is up to date" / "Successfully built" summary lines.
408
+ *
409
+ * Patterns dropped (all common Docker progress chatter):
410
+ * - "<hash>: Pulling fs layer"
411
+ * - "<hash>: Waiting"
412
+ * - "<hash>: Verifying Checksum"
413
+ * - "<hash>: Downloading [====> ] 12.3MB/45.6MB"
414
+ * - "<hash>: Pull complete" / "Extracting" / "Download complete"
415
+ * - BuildKit progress lines: "#5 [internal] load build context"
416
+ * - Build context transfer lines
417
+ */
418
+ function compressDockerProgress(output) {
419
+ const lines = output.split('\n');
420
+ const kept = [];
421
+ for (const line of lines) {
422
+ const trimmed = line.trim();
423
+ // Per-layer progress patterns (hash prefix + colon + status)
424
+ if (/^[a-f0-9]{12}:\s+(Pulling fs layer|Waiting|Verifying Checksum|Download complete|Pull complete|Extracting|Downloading|Pushing|Pushed|Mounted from|Layer already exists|Preparing|Already exists)/i.test(trimmed)) {
425
+ continue;
426
+ }
427
+ // BuildKit transfer lines: "#N transferring context: 1.23MB"
428
+ if (/^#\d+\s+(transferring|sha256:|extracting|naming to|exporting layers|writing image)/.test(trimmed))
429
+ continue;
430
+ // Plain "Downloading [====> ] 12MB/45MB" without hash prefix
431
+ if (/^\s*Downloading\s+\[/.test(line))
432
+ continue;
433
+ kept.push(line);
434
+ }
435
+ return kept.join('\n');
436
+ }
437
+ // ─── kubectl describe (collapse indentation noise, dedupe events) ───────────
438
+ /**
439
+ * Compress `kubectl describe` output. The verbose describe format is highly
440
+ * indented and includes long Events tables that often duplicate the same
441
+ * message N times ("Back-off restarting failed container").
442
+ *
443
+ * Strategy:
444
+ * - Trim trailing whitespace on every line (huge amount of padding)
445
+ * - Collapse 3+ consecutive blank lines to a single blank
446
+ * - In the Events: section, dedupe identical messages — keep first occurrence
447
+ * plus a count of repeats ("(x42)")
448
+ */
449
+ function compressKubectlDescribe(output) {
450
+ const lines = output.split('\n');
451
+ const kept = [];
452
+ let inEvents = false;
453
+ let consecutiveBlanks = 0;
454
+ // Track event message → count (only used inside Events: section)
455
+ const eventCounts = new Map();
456
+ const eventOrder = [];
457
+ for (const line of lines) {
458
+ const rstripped = line.replace(/\s+$/, '');
459
+ const trimmed = rstripped.trim();
460
+ // Section header transitions
461
+ if (/^Events:\s*$/.test(trimmed)) {
462
+ inEvents = true;
463
+ kept.push(rstripped);
464
+ continue;
465
+ }
466
+ if (inEvents && /^\S/.test(rstripped) && !/^Events:/.test(trimmed)) {
467
+ // A non-indented, non-Events: line means we've left the Events section.
468
+ // Flush deduped events first.
469
+ flushEvents(kept, eventCounts, eventOrder);
470
+ inEvents = false;
471
+ }
472
+ if (inEvents) {
473
+ // Inside Events table — collect by "type + reason + message" (drop timestamp/age columns)
474
+ // Lines look like:
475
+ // " Type Reason Age From Message"
476
+ // " ---- ------ --- ---- -------"
477
+ // " Warning BackOff 1m kubelet Back-off restarting failed container"
478
+ if (trimmed === '' || /^(Type|----)/.test(trimmed)) {
479
+ kept.push(rstripped);
480
+ continue;
481
+ }
482
+ // Extract the message (everything after the first 4 whitespace-separated columns)
483
+ const parts = trimmed.split(/\s{2,}/); // 2+ spaces between columns
484
+ const key = parts.length > 4 ? parts.slice(-1)[0] : trimmed;
485
+ const count = eventCounts.get(key) ?? 0;
486
+ if (count === 0)
487
+ eventOrder.push(rstripped);
488
+ eventCounts.set(key, count + 1);
489
+ continue;
490
+ }
491
+ // Collapse runs of blank lines
492
+ if (trimmed === '') {
493
+ consecutiveBlanks++;
494
+ if (consecutiveBlanks > 1)
495
+ continue;
496
+ }
497
+ else {
498
+ consecutiveBlanks = 0;
499
+ }
500
+ kept.push(rstripped);
501
+ }
502
+ // Flush any pending events at the end
503
+ if (inEvents)
504
+ flushEvents(kept, eventCounts, eventOrder);
505
+ return kept.join('\n');
506
+ }
507
+ function flushEvents(kept, counts, order) {
508
+ for (const line of order) {
509
+ const parts = line.trim().split(/\s{2,}/);
510
+ const key = parts.length > 4 ? parts.slice(-1)[0] : line.trim();
511
+ const count = counts.get(key) ?? 1;
512
+ if (count > 1) {
513
+ kept.push(`${line} (x${String(count)})`);
514
+ }
515
+ else {
516
+ kept.push(line);
517
+ }
518
+ }
519
+ counts.clear();
520
+ order.length = 0;
521
+ }
299
522
  // ─── curl / wget ────────────────────────────────────────────────────────────
300
523
  function compressCurlOutput(output) {
301
524
  const lines = output.split('\n');
@@ -79,6 +79,11 @@ function readGitRemote(projectPath) {
79
79
  }
80
80
  /**
81
81
  * Read description from README.md or COMPILR.md (first non-heading paragraph).
82
+ *
83
+ * Skips noise commonly found above the first prose paragraph in auto-generated
84
+ * READMEs: headings, badge images, HTML comments, block-quote callouts, URL-only
85
+ * lines, and `**Key**: value` bold-metadata lines (e.g. Lovable's `**URL**: ...`,
86
+ * v0/Vercel templates with `**Demo**: ...`).
82
87
  */
83
88
  function readDescription(projectPath) {
84
89
  const candidates = ['README.md', 'readme.md', 'COMPILR.md'];
@@ -89,7 +94,7 @@ function readDescription(projectPath) {
89
94
  try {
90
95
  const content = readFileSync(filePath, 'utf-8');
91
96
  const lines = content.split('\n');
92
- // Find first non-empty, non-heading line
97
+ // Find first non-empty, non-heading, non-metadata line
93
98
  for (const line of lines) {
94
99
  const trimmed = line.trim();
95
100
  if (!trimmed)
@@ -99,7 +104,13 @@ function readDescription(projectPath) {
99
104
  if (trimmed.startsWith('!['))
100
105
  continue; // badge images
101
106
  if (trimmed.startsWith('<!--'))
102
- continue;
107
+ continue; // HTML comments
108
+ if (trimmed.startsWith('>'))
109
+ continue; // block-quote callouts / "Note:" disclaimers
110
+ if (/^\*\*[\w\s/-]+\*\*\s*:/.test(trimmed))
111
+ continue; // bold-key metadata: **URL**: ..., **Live Demo**: ...
112
+ if (/^https?:\/\/\S+\s*$/.test(trimmed))
113
+ continue; // URL-only lines
103
114
  // Found a content line — take up to 200 chars
104
115
  return trimmed.length > 200 ? trimmed.slice(0, 200) + '...' : trimmed;
105
116
  }
package/dist/index.d.ts CHANGED
@@ -56,8 +56,8 @@ export type { SystemPromptContext, BuildResult, SystemPromptModule, ModuleCondit
56
56
  export type { ProjectType, ProjectStatus, RepoPattern, WorkflowMode, LifecycleState, WorkItemType, WorkItemStatus, WorkItemPriority, GuidedStep, DocumentType, PlanStatus, Project, WorkItem, ProjectDocument, Plan, PlanSummary, PlanWithWorkItem, HistoryEntry, CreateProjectInput, UpdateProjectInput, ProjectListOptions, CreateWorkItemInput, UpdateWorkItemInput, QueryWorkItemsInput, CreateDocumentInput, UpdateDocumentInput, CreatePlanInput, UpdatePlanInput, ListPlansOptions, WorkItemQueryResult, ProjectListResult, BulkCreateItem, WorkItemComment, CreateCommentInput, UpdateCommentInput, IProjectRepository, IWorkItemRepository, IDocumentRepository, IPlanRepository, ICommentRepository, IAnchorService, IArtifactService, IEpisodeService, AnchorData, ArtifactType, ArtifactData, ArtifactSummaryData, WorkEpisode, ProjectWorkSummary, PlatformContext, PlatformToolsConfig, PlatformHooks, StepCriteria, } from './platform/index.js';
57
57
  export { createSQLiteRepositories, SQLiteProjectRepository, SQLiteWorkItemRepository, SQLiteDocumentRepository, SQLitePlanRepository, SQLiteCommentRepository, getDatabase, closeDatabase, closeAllDatabases, databaseExists, SCHEMA_VERSION, SCHEMA_SQL, } from './platform/index.js';
58
58
  export type { SQLiteRepositories, CreateSQLiteRepositoriesOptions, ProjectDeleteHooks, ProjectRecord, WorkItemRecord, ProjectDocumentRecord, WorkItemCommentRecord, } from './platform/index.js';
59
- export { createAskUserTool, createAskUserSimpleTool, createProposeAlternativesTool, } from './tools/index.js';
60
- export type { AskUserQuestion, AskUserInput, AskUserResult, AskUserHandler, AskUserSimpleInput, AskUserSimpleResult, AskUserSimpleHandler, Alternative, ProposeAlternativesInput, ProposeAlternativesResult, ProposeAlternativesHandler, } from './tools/index.js';
59
+ export { createAskUserTool, createAskUserSimpleTool, createProposeAlternativesTool, createInteractiveFlowTool, validateFlow, INTERACTIVE_FLOW_INPUT_SCHEMA, } from './tools/index.js';
60
+ export type { AskUserQuestion, AskUserInput, AskUserResult, AskUserHandler, AskUserSimpleInput, AskUserSimpleResult, AskUserSimpleHandler, Alternative, ProposeAlternativesInput, ProposeAlternativesResult, ProposeAlternativesHandler, Flow, FlowTone, FlowNode, QuestionNode, InfoNode, BranchNode, SummaryNode, QuestionInput, Choice, Proposal, NextRef, BranchRoute, BranchCondition, NodeId, IconName, Tint, RenderVariant, AnswerValue, InteractiveFlowInput, InteractiveFlowResult, InteractiveFlowHandler, FlowValidationResult, FlowValidationError, FlowErrorCode, } from './tools/index.js';
61
61
  export { EntitlementCache, UNLIMITED, OFFLINE_FALLBACK_LIMITS, DailyCounter, formatLimitMessage, formatTimeUntilReset, formatUpgradeHint, } from './entitlements/index.js';
62
62
  export type { TierLimits, EntitlementResponse, LimitCheckResult, IEntitlementStore, EntitlementCacheConfig, } from './entitlements/index.js';
63
63
  export { detectProject, suggestProjectType, detectCommon } from './detection/index.js';
package/dist/index.js CHANGED
@@ -131,7 +131,7 @@ export { createSQLiteRepositories, SQLiteProjectRepository, SQLiteWorkItemReposi
131
131
  // =============================================================================
132
132
  // User Interaction Tools (ask_user, ask_user_simple)
133
133
  // =============================================================================
134
- export { createAskUserTool, createAskUserSimpleTool, createProposeAlternativesTool, } from './tools/index.js';
134
+ export { createAskUserTool, createAskUserSimpleTool, createProposeAlternativesTool, createInteractiveFlowTool, validateFlow, INTERACTIVE_FLOW_INPUT_SCHEMA, } from './tools/index.js';
135
135
  // =============================================================================
136
136
  // Entitlements (server-driven tier management)
137
137
  // =============================================================================
@@ -6,5 +6,7 @@
6
6
  */
7
7
  export { createAskUserTool, createAskUserSimpleTool } from './ask-user-tools.js';
8
8
  export { createProposeAlternativesTool } from './propose-alternatives-tool.js';
9
+ export { createInteractiveFlowTool, validateFlow, INTERACTIVE_FLOW_INPUT_SCHEMA, } from './interactive-flow-tool.js';
9
10
  export type { AskUserQuestion, AskUserInput, AskUserResult, AskUserHandler, AskUserSimpleInput, AskUserSimpleResult, AskUserSimpleHandler, } from './ask-user-tools.js';
10
11
  export type { Alternative, ProposeAlternativesInput, ProposeAlternativesResult, ProposeAlternativesHandler, } from './propose-alternatives-tool.js';
12
+ export type { Flow, FlowTone, Node as FlowNode, QuestionNode, InfoNode, BranchNode, SummaryNode, QuestionInput, Choice, Proposal, NextRef, BranchRoute, BranchCondition, NodeId, IconName, Tint, RenderVariant, AnswerValue, InteractiveFlowInput, InteractiveFlowResult, InteractiveFlowHandler, FlowValidationResult, FlowValidationError, FlowErrorCode, } from './interactive-flow-tool.js';
@@ -6,3 +6,4 @@
6
6
  */
7
7
  export { createAskUserTool, createAskUserSimpleTool } from './ask-user-tools.js';
8
8
  export { createProposeAlternativesTool } from './propose-alternatives-tool.js';
9
+ export { createInteractiveFlowTool, validateFlow, INTERACTIVE_FLOW_INPUT_SCHEMA, } from './interactive-flow-tool.js';
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Interactive Flow Tool — Agent-authored navigable decision trees
3
+ *
4
+ * The agent builds a JSON DSL describing a tree of nodes; the consumer
5
+ * (Desktop, eventually CLI) renders the flow as a modal/screen-stack the user
6
+ * navigates. The user's path through the tree + final answers are returned to
7
+ * the agent as a structured artifact.
8
+ *
9
+ * Same factory pattern as ask-user-tools.ts: SDK defines the schema/types/
10
+ * validator; the consumer provides the UI handler.
11
+ *
12
+ * Spec: project-docs/00-requirements/compilr-dev-desktop/interactive-flow-dsl-spec.md
13
+ * Plan: project-docs/00-requirements/compilr-dev-desktop/interactive-flow-dsl-implementation-plan.md
14
+ */
15
+ import type { Tool } from '@compilr-dev/agents';
16
+ /** Unique identifier for a node within a flow */
17
+ export type NodeId = string;
18
+ /** Top-level flow definition the agent authors */
19
+ export interface Flow {
20
+ /** Title shown in the modal header */
21
+ title: string;
22
+ /** Optional subhead under the title */
23
+ description?: string;
24
+ /** ID of the first node to render */
25
+ startNode: NodeId;
26
+ /** All nodes in the flow, keyed by ID */
27
+ nodes: Record<NodeId, Node>;
28
+ /** Motion style — see spec §5; default 'default' */
29
+ tone?: FlowTone;
30
+ /** Visual density — default 'comfortable' */
31
+ density?: 'compact' | 'comfortable';
32
+ }
33
+ /** Motion intensity. 'minimal' = opacity fades only (also forced by reduced-motion). */
34
+ export type FlowTone = 'default' | 'minimal';
35
+ /** Node union — the four primitives */
36
+ export type Node = QuestionNode | InfoNode | BranchNode | SummaryNode;
37
+ /** Ask the user for input */
38
+ export interface QuestionNode {
39
+ type: 'question';
40
+ id: NodeId;
41
+ prompt: string;
42
+ description?: string;
43
+ input: QuestionInput;
44
+ /** Optional renderer override; consumer infers from input shape if omitted */
45
+ render?: RenderVariant;
46
+ next: NextRef;
47
+ }
48
+ /** Show information; no input */
49
+ export interface InfoNode {
50
+ type: 'info';
51
+ id: NodeId;
52
+ title: string;
53
+ /** Markdown body */
54
+ body: string;
55
+ render?: RenderVariant;
56
+ next: NextRef;
57
+ }
58
+ /** Pure routing logic — no UI */
59
+ export interface BranchNode {
60
+ type: 'branch';
61
+ id: NodeId;
62
+ routes: BranchRoute[];
63
+ /** Fallback target if no route matches */
64
+ default: NodeId;
65
+ }
66
+ /** Terminal recap — flow ends when user confirms */
67
+ export interface SummaryNode {
68
+ type: 'summary';
69
+ id: NodeId;
70
+ title: string;
71
+ /** Optional markdown shown above the answer table */
72
+ recap?: string;
73
+ render?: RenderVariant;
74
+ }
75
+ /** Routing rule for a BranchNode */
76
+ export interface BranchRoute {
77
+ when: BranchCondition;
78
+ goto: NodeId;
79
+ }
80
+ /** Condition language is intentionally tiny in v1 — no &&/|| */
81
+ export type BranchCondition = {
82
+ questionId: NodeId;
83
+ equals: string;
84
+ } | {
85
+ questionId: NodeId;
86
+ includes: string;
87
+ } | {
88
+ questionId: NodeId;
89
+ notEquals: string;
90
+ };
91
+ /** Question input modes — each maps to a set of compatible renderers */
92
+ export type QuestionInput = {
93
+ mode: 'single';
94
+ choices: Choice[];
95
+ } | {
96
+ mode: 'multi';
97
+ choices: Choice[];
98
+ min?: number;
99
+ max?: number;
100
+ } | {
101
+ mode: 'text';
102
+ placeholder?: string;
103
+ multiline?: boolean;
104
+ } | {
105
+ mode: 'proposal';
106
+ options: Proposal[];
107
+ };
108
+ /** A selectable choice for single/multi modes */
109
+ export interface Choice {
110
+ id: string;
111
+ label: string;
112
+ description?: string;
113
+ icon?: IconName;
114
+ tint?: Tint;
115
+ }
116
+ /** A proposal with pros/cons for proposal mode */
117
+ export interface Proposal {
118
+ id: string;
119
+ label: string;
120
+ pros?: string[];
121
+ cons?: string[];
122
+ icon?: IconName;
123
+ tint?: Tint;
124
+ }
125
+ /** Where to go after this node */
126
+ export type NextRef = NodeId | {
127
+ byAnswer: Record<string, NodeId>;
128
+ default?: NodeId;
129
+ };
130
+ /** Lucide icon name (kebab-case slug per lucide.dev URLs) */
131
+ export type IconName = string;
132
+ /** Semantic color hint — consumer maps to theme colors */
133
+ export type Tint = 'accent' | 'success' | 'warning' | 'danger' | 'muted';
134
+ /**
135
+ * Renderer name — specific to node type and (for questions) input mode.
136
+ * Consumer-side dispatcher uses this name to look up the React component.
137
+ * Validation here only checks that the renderer is compatible with the node's
138
+ * mode (e.g., 'card-grid' on a text-mode question is rejected).
139
+ */
140
+ export type RenderVariant = string;
141
+ export interface InteractiveFlowInput {
142
+ flow: Flow;
143
+ }
144
+ /** Value collected from a node (Question only — info/branch/summary don't produce values) */
145
+ export type AnswerValue = string | string[];
146
+ export interface InteractiveFlowResult {
147
+ /** False if the user aborted (closed modal, switched conversation, etc.) */
148
+ completed: boolean;
149
+ /** Every node visited, in order — including backtracked nodes */
150
+ path: NodeId[];
151
+ /** Final answers — answers downstream of a backtrack point are wiped */
152
+ answers: Record<NodeId, AnswerValue>;
153
+ /** Present iff !completed — the node where the user aborted */
154
+ abortedAt?: NodeId;
155
+ /** Wall-clock time the user spent */
156
+ durationMs: number;
157
+ /** Validator warnings that didn't block execution (e.g., orphan nodes) */
158
+ warnings?: string[];
159
+ }
160
+ /** UI handler — the consumer provides this; the SDK calls it once validation passes */
161
+ export type InteractiveFlowHandler = (input: InteractiveFlowInput) => Promise<InteractiveFlowResult>;
162
+ /** Error codes — see spec §4 */
163
+ export type FlowErrorCode = 'MISSING_START_NODE' | 'DUPLICATE_NODE_ID' | 'UNRESOLVED_NEXT' | 'UNRESOLVED_BRANCH' | 'INVALID_ICON' | 'INVALID_RENDERER' | 'INVALID_NODE_TYPE' | 'INVALID_CONDITION';
164
+ export interface FlowValidationError {
165
+ code: FlowErrorCode;
166
+ message: string;
167
+ /** Node where the error was found, if applicable */
168
+ nodeId?: NodeId;
169
+ }
170
+ export interface FlowValidationResult {
171
+ ok: boolean;
172
+ errors: FlowValidationError[];
173
+ warnings: string[];
174
+ }
175
+ /**
176
+ * Validate a flow. Accepts `unknown` because inputs flow in as JSON from the
177
+ * agent and TS types are lies at the boundary — runtime defensive checks
178
+ * happen before the cast to Flow.
179
+ */
180
+ export declare function validateFlow(flowInput: unknown): FlowValidationResult;
181
+ /**
182
+ * Create the `build_interactive_flow` tool with a custom UI handler.
183
+ *
184
+ * The SDK validates the input flow (schema + cross-references). If validation
185
+ * fails, the tool returns an error to the agent without invoking the handler.
186
+ * If validation passes, the handler is called and its Promise is awaited.
187
+ * Warnings (e.g., orphan nodes) are passed through in the result.
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * // Desktop: send IPC to renderer
192
+ * const flowTool = createInteractiveFlowTool(async (input) => {
193
+ * return await sendInteractiveFlowToRenderer(input);
194
+ * });
195
+ * ```
196
+ */
197
+ export declare function createInteractiveFlowTool(handler: InteractiveFlowHandler): Tool<InteractiveFlowInput>;
198
+ /**
199
+ * JSON Schema for `build_interactive_flow` input. Validates structural shape;
200
+ * cross-reference validation (next/branch/icon/renderer) happens in validateFlow().
201
+ *
202
+ * The schema is deliberately permissive on `nodes` (object<string, object>)
203
+ * rather than enumerating each node-type shape — the variant validation lives
204
+ * in code where errors carry more context for the agent to act on.
205
+ */
206
+ export declare const INTERACTIVE_FLOW_INPUT_SCHEMA: {
207
+ type: "object";
208
+ properties: {
209
+ flow: {
210
+ type: string;
211
+ properties: {
212
+ title: {
213
+ type: string;
214
+ description: string;
215
+ };
216
+ description: {
217
+ type: string;
218
+ description: string;
219
+ };
220
+ startNode: {
221
+ type: string;
222
+ description: string;
223
+ };
224
+ nodes: {
225
+ type: string;
226
+ description: string;
227
+ additionalProperties: {
228
+ type: string;
229
+ };
230
+ };
231
+ tone: {
232
+ type: string;
233
+ enum: string[];
234
+ description: string;
235
+ };
236
+ density: {
237
+ type: string;
238
+ enum: string[];
239
+ description: string;
240
+ };
241
+ };
242
+ required: string[];
243
+ };
244
+ };
245
+ required: string[];
246
+ };
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Interactive Flow Tool — Agent-authored navigable decision trees
3
+ *
4
+ * The agent builds a JSON DSL describing a tree of nodes; the consumer
5
+ * (Desktop, eventually CLI) renders the flow as a modal/screen-stack the user
6
+ * navigates. The user's path through the tree + final answers are returned to
7
+ * the agent as a structured artifact.
8
+ *
9
+ * Same factory pattern as ask-user-tools.ts: SDK defines the schema/types/
10
+ * validator; the consumer provides the UI handler.
11
+ *
12
+ * Spec: project-docs/00-requirements/compilr-dev-desktop/interactive-flow-dsl-spec.md
13
+ * Plan: project-docs/00-requirements/compilr-dev-desktop/interactive-flow-dsl-implementation-plan.md
14
+ */
15
+ import { defineTool } from '@compilr-dev/agents';
16
+ /** Renderers compatible with each input mode (consumer-side defaults documented in spec §3) */
17
+ const QUESTION_RENDERERS = {
18
+ single: ['radio', 'tile-row', 'segmented', 'card-grid'],
19
+ multi: ['checkbox-list', 'tag-cloud', 'card-grid-multi'],
20
+ text: ['single-line', 'multiline', 'code'],
21
+ proposal: ['stacked-cards', 'comparison-table', 'detailed-cards'],
22
+ };
23
+ const INFO_RENDERERS = [
24
+ 'callout',
25
+ 'detail-card',
26
+ 'bullet-list',
27
+ 'numbered-steps',
28
+ 'comparison',
29
+ 'warning',
30
+ 'success',
31
+ ];
32
+ const SUMMARY_RENDERERS = ['compact', 'detailed'];
33
+ /**
34
+ * Lucide icon names use kebab-case slugs (e.g., 'database', 'smartphone',
35
+ * 'a-arrow-down'). v1 validation is FORMAT-only: lowercase letters, digits,
36
+ * and single hyphens. A future iteration will load the actual Lucide catalog
37
+ * and reject unknown slugs; for now Desktop falls back gracefully when a
38
+ * slug doesn't map to a component.
39
+ */
40
+ const ICON_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
41
+ /**
42
+ * Validate a flow. Accepts `unknown` because inputs flow in as JSON from the
43
+ * agent and TS types are lies at the boundary — runtime defensive checks
44
+ * happen before the cast to Flow.
45
+ */
46
+ export function validateFlow(flowInput) {
47
+ const errors = [];
48
+ const warnings = [];
49
+ // Defensive: missing top-level fields
50
+ if (!flowInput || typeof flowInput !== 'object') {
51
+ return {
52
+ ok: false,
53
+ errors: [{ code: 'INVALID_NODE_TYPE', message: 'Flow must be an object' }],
54
+ warnings,
55
+ };
56
+ }
57
+ const flowMaybe = flowInput;
58
+ if (!flowMaybe.nodes || typeof flowMaybe.nodes !== 'object') {
59
+ return {
60
+ ok: false,
61
+ errors: [{ code: 'INVALID_NODE_TYPE', message: 'Flow.nodes must be an object' }],
62
+ warnings,
63
+ };
64
+ }
65
+ // After both runtime gates, it's safe to narrow to Flow for the rest of the function.
66
+ const flow = flowInput;
67
+ const nodeIds = Object.keys(flow.nodes);
68
+ // 1. startNode must exist
69
+ if (!flow.startNode || !(flow.startNode in flow.nodes)) {
70
+ errors.push({
71
+ code: 'MISSING_START_NODE',
72
+ message: `startNode '${flow.startNode}' is not defined in flow.nodes`,
73
+ });
74
+ }
75
+ // 2. Each node's id must match the key it lives under
76
+ for (const id of nodeIds) {
77
+ const node = flow.nodes[id];
78
+ if (node.id !== id) {
79
+ errors.push({
80
+ code: 'DUPLICATE_NODE_ID',
81
+ message: `Node key '${id}' does not match node.id '${node.id}'`,
82
+ nodeId: id,
83
+ });
84
+ }
85
+ }
86
+ // 3. Per-node validation: type, references, icons, renderers, conditions
87
+ for (const id of nodeIds) {
88
+ const node = flow.nodes[id];
89
+ validateNode(node, flow.nodes, errors);
90
+ }
91
+ // 4. Reachability — warn on orphans
92
+ if (errors.length === 0 && flow.startNode in flow.nodes) {
93
+ const reachable = computeReachable(flow);
94
+ for (const id of nodeIds) {
95
+ if (!reachable.has(id)) {
96
+ warnings.push(`Node '${id}' is unreachable from startNode`);
97
+ }
98
+ }
99
+ }
100
+ return { ok: errors.length === 0, errors, warnings };
101
+ }
102
+ function validateNode(node, allNodes, errors) {
103
+ switch (node.type) {
104
+ case 'question':
105
+ validateQuestion(node, allNodes, errors);
106
+ break;
107
+ case 'info':
108
+ validateInfo(node, allNodes, errors);
109
+ break;
110
+ case 'branch':
111
+ validateBranch(node, allNodes, errors);
112
+ break;
113
+ case 'summary':
114
+ validateSummary(node, errors);
115
+ break;
116
+ default: {
117
+ // Defensive — TypeScript should prevent this but JSON inputs can be malformed
118
+ const unknown = node;
119
+ errors.push({
120
+ code: 'INVALID_NODE_TYPE',
121
+ message: `Unknown node type '${String(unknown.type)}'`,
122
+ nodeId: unknown.id,
123
+ });
124
+ }
125
+ }
126
+ }
127
+ function validateQuestion(node, allNodes, errors) {
128
+ // Input mode must have a valid renderer if specified. The lookup CAN miss
129
+ // at runtime if the agent passed an unknown mode in the JSON — TS narrowing
130
+ // doesn't help us across the boundary, hence the explicit widened type.
131
+ const validRenderers = QUESTION_RENDERERS[node.input.mode];
132
+ if (!validRenderers) {
133
+ errors.push({
134
+ code: 'INVALID_NODE_TYPE',
135
+ message: `Unknown question input mode '${node.input.mode}'`,
136
+ nodeId: node.id,
137
+ });
138
+ return;
139
+ }
140
+ if (node.render && !validRenderers.includes(node.render)) {
141
+ errors.push({
142
+ code: 'INVALID_RENDERER',
143
+ message: `Renderer '${node.render}' is not valid for ${node.input.mode}-mode questions (valid: ${validRenderers.join(', ')})`,
144
+ nodeId: node.id,
145
+ });
146
+ }
147
+ // Validate icons on choices/proposals
148
+ if (node.input.mode === 'single' || node.input.mode === 'multi') {
149
+ for (const choice of node.input.choices) {
150
+ if (choice.icon !== undefined)
151
+ checkIcon(choice.icon, node.id, errors);
152
+ }
153
+ }
154
+ else if (node.input.mode === 'proposal') {
155
+ for (const opt of node.input.options) {
156
+ if (opt.icon !== undefined)
157
+ checkIcon(opt.icon, node.id, errors);
158
+ }
159
+ }
160
+ // Validate next references
161
+ validateNextRef(node.next, node, allNodes, errors);
162
+ }
163
+ function validateInfo(node, allNodes, errors) {
164
+ if (node.render && !INFO_RENDERERS.includes(node.render)) {
165
+ errors.push({
166
+ code: 'INVALID_RENDERER',
167
+ message: `Renderer '${node.render}' is not valid for info nodes (valid: ${INFO_RENDERERS.join(', ')})`,
168
+ nodeId: node.id,
169
+ });
170
+ }
171
+ validateNextRef(node.next, node, allNodes, errors);
172
+ }
173
+ function validateBranch(node, allNodes, errors) {
174
+ // All routes must reference existing nodes and use valid conditions
175
+ for (let i = 0; i < node.routes.length; i++) {
176
+ const route = node.routes[i];
177
+ if (!isValidCondition(route.when)) {
178
+ errors.push({
179
+ code: 'INVALID_CONDITION',
180
+ message: `Branch route ${String(i)} has an invalid condition shape`,
181
+ nodeId: node.id,
182
+ });
183
+ }
184
+ if (!(route.goto in allNodes)) {
185
+ errors.push({
186
+ code: 'UNRESOLVED_BRANCH',
187
+ message: `Branch route ${String(i)} goto '${route.goto}' is not defined in flow.nodes`,
188
+ nodeId: node.id,
189
+ });
190
+ }
191
+ }
192
+ if (!node.default || !(node.default in allNodes)) {
193
+ errors.push({
194
+ code: 'UNRESOLVED_BRANCH',
195
+ message: `Branch default '${node.default}' is not defined in flow.nodes`,
196
+ nodeId: node.id,
197
+ });
198
+ }
199
+ }
200
+ function validateSummary(node, errors) {
201
+ if (node.render && !SUMMARY_RENDERERS.includes(node.render)) {
202
+ errors.push({
203
+ code: 'INVALID_RENDERER',
204
+ message: `Renderer '${node.render}' is not valid for summary nodes (valid: ${SUMMARY_RENDERERS.join(', ')})`,
205
+ nodeId: node.id,
206
+ });
207
+ }
208
+ // Summary has no next — flow terminates here
209
+ }
210
+ function validateNextRef(next, node, allNodes, errors) {
211
+ if (typeof next === 'string') {
212
+ if (!(next in allNodes)) {
213
+ errors.push({
214
+ code: 'UNRESOLVED_NEXT',
215
+ message: `Node '${node.id}' next references '${next}' which is not defined in flow.nodes`,
216
+ nodeId: node.id,
217
+ });
218
+ }
219
+ return;
220
+ }
221
+ // byAnswer object
222
+ for (const [answer, target] of Object.entries(next.byAnswer)) {
223
+ if (!(target in allNodes)) {
224
+ errors.push({
225
+ code: 'UNRESOLVED_NEXT',
226
+ message: `Node '${node.id}' next.byAnswer['${answer}'] references '${target}' which is not defined in flow.nodes`,
227
+ nodeId: node.id,
228
+ });
229
+ }
230
+ }
231
+ if (next.default !== undefined && !(next.default in allNodes)) {
232
+ errors.push({
233
+ code: 'UNRESOLVED_NEXT',
234
+ message: `Node '${node.id}' next.default references '${next.default}' which is not defined in flow.nodes`,
235
+ nodeId: node.id,
236
+ });
237
+ }
238
+ }
239
+ function isValidCondition(condition) {
240
+ // Accepts unknown because the agent's JSON might pass anything; TS narrows BranchCondition
241
+ // too tightly to validate the runtime shape.
242
+ if (!condition || typeof condition !== 'object')
243
+ return false;
244
+ const c = condition;
245
+ if (typeof c.questionId !== 'string')
246
+ return false;
247
+ return (typeof c.equals === 'string' ||
248
+ typeof c.includes === 'string' ||
249
+ typeof c.notEquals === 'string');
250
+ }
251
+ function checkIcon(icon, nodeId, errors) {
252
+ if (!ICON_NAME_RE.test(icon)) {
253
+ errors.push({
254
+ code: 'INVALID_ICON',
255
+ message: `Icon '${icon}' is not a valid Lucide slug (lowercase kebab-case, e.g., 'database', 'smartphone', 'a-arrow-down')`,
256
+ nodeId,
257
+ });
258
+ }
259
+ }
260
+ /** Compute set of node IDs reachable from startNode via any path */
261
+ function computeReachable(flow) {
262
+ const reachable = new Set();
263
+ const stack = [flow.startNode];
264
+ while (stack.length > 0) {
265
+ const id = stack.pop();
266
+ if (!id || reachable.has(id) || !(id in flow.nodes))
267
+ continue;
268
+ reachable.add(id);
269
+ const node = flow.nodes[id];
270
+ switch (node.type) {
271
+ case 'question':
272
+ case 'info':
273
+ addNextTargets(node.next, stack);
274
+ break;
275
+ case 'branch':
276
+ for (const route of node.routes)
277
+ stack.push(route.goto);
278
+ stack.push(node.default);
279
+ break;
280
+ case 'summary':
281
+ // terminal
282
+ break;
283
+ }
284
+ }
285
+ return reachable;
286
+ }
287
+ function addNextTargets(next, stack) {
288
+ if (typeof next === 'string') {
289
+ stack.push(next);
290
+ return;
291
+ }
292
+ for (const target of Object.values(next.byAnswer))
293
+ stack.push(target);
294
+ if (next.default !== undefined)
295
+ stack.push(next.default);
296
+ }
297
+ // =============================================================================
298
+ // Factory
299
+ // =============================================================================
300
+ /**
301
+ * Create the `build_interactive_flow` tool with a custom UI handler.
302
+ *
303
+ * The SDK validates the input flow (schema + cross-references). If validation
304
+ * fails, the tool returns an error to the agent without invoking the handler.
305
+ * If validation passes, the handler is called and its Promise is awaited.
306
+ * Warnings (e.g., orphan nodes) are passed through in the result.
307
+ *
308
+ * @example
309
+ * ```typescript
310
+ * // Desktop: send IPC to renderer
311
+ * const flowTool = createInteractiveFlowTool(async (input) => {
312
+ * return await sendInteractiveFlowToRenderer(input);
313
+ * });
314
+ * ```
315
+ */
316
+ export function createInteractiveFlowTool(handler) {
317
+ return defineTool({
318
+ name: 'build_interactive_flow',
319
+ description: 'Render a navigable decision tree (modal wizard) to the user. ' +
320
+ 'Use this when a decision branches into multiple paths that benefit from ' +
321
+ 'visual exploration — the user can move forward, go back, and the agent ' +
322
+ 'gets back both their answers and the path they took through the tree. ' +
323
+ 'Prefer over ask_user when the decision has 2+ branching considerations.',
324
+ inputSchema: INTERACTIVE_FLOW_INPUT_SCHEMA,
325
+ execute: async (input) => {
326
+ try {
327
+ const validation = validateFlow(input.flow);
328
+ if (!validation.ok) {
329
+ const summary = validation.errors
330
+ .map((e) => `[${e.code}]${e.nodeId ? ` (node '${e.nodeId}')` : ''} ${e.message}`)
331
+ .join('\n');
332
+ return {
333
+ success: false,
334
+ error: `Interactive flow validation failed:\n${summary}`,
335
+ };
336
+ }
337
+ const result = await handler(input);
338
+ if (validation.warnings.length > 0) {
339
+ result.warnings = [...(result.warnings ?? []), ...validation.warnings];
340
+ }
341
+ return { success: true, result };
342
+ }
343
+ catch (err) {
344
+ return {
345
+ success: false,
346
+ error: `Failed to run interactive flow: ${err instanceof Error ? err.message : String(err)}`,
347
+ };
348
+ }
349
+ },
350
+ silent: true,
351
+ });
352
+ }
353
+ // =============================================================================
354
+ // JSON Schema (for the agent's tool registration)
355
+ // =============================================================================
356
+ /**
357
+ * JSON Schema for `build_interactive_flow` input. Validates structural shape;
358
+ * cross-reference validation (next/branch/icon/renderer) happens in validateFlow().
359
+ *
360
+ * The schema is deliberately permissive on `nodes` (object<string, object>)
361
+ * rather than enumerating each node-type shape — the variant validation lives
362
+ * in code where errors carry more context for the agent to act on.
363
+ */
364
+ export const INTERACTIVE_FLOW_INPUT_SCHEMA = {
365
+ type: 'object',
366
+ properties: {
367
+ flow: {
368
+ type: 'object',
369
+ properties: {
370
+ title: { type: 'string', description: 'Modal header title' },
371
+ description: { type: 'string', description: 'Optional subhead under the title' },
372
+ startNode: { type: 'string', description: 'ID of the first node to render' },
373
+ nodes: {
374
+ type: 'object',
375
+ description: 'Map of node ID → node. Each node has a `type` field: question, info, branch, or summary. See tool documentation for the full DSL schema.',
376
+ additionalProperties: { type: 'object' },
377
+ },
378
+ tone: {
379
+ type: 'string',
380
+ enum: ['default', 'minimal'],
381
+ description: 'Motion style; default "default"',
382
+ },
383
+ density: {
384
+ type: 'string',
385
+ enum: ['compact', 'comfortable'],
386
+ description: 'Visual density; default "comfortable"',
387
+ },
388
+ },
389
+ required: ['title', 'startNode', 'nodes'],
390
+ },
391
+ },
392
+ required: ['flow'],
393
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",