@compilr-dev/sdk 0.10.13 → 0.10.15

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/dist/index.d.ts CHANGED
@@ -57,7 +57,7 @@ export type { ProjectType, ProjectStatus, RepoPattern, WorkflowMode, LifecycleSt
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
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';
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, AbortReason, 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';
@@ -9,4 +9,4 @@ export { createProposeAlternativesTool } from './propose-alternatives-tool.js';
9
9
  export { createInteractiveFlowTool, validateFlow, INTERACTIVE_FLOW_INPUT_SCHEMA, } from './interactive-flow-tool.js';
10
10
  export type { AskUserQuestion, AskUserInput, AskUserResult, AskUserHandler, AskUserSimpleInput, AskUserSimpleResult, AskUserSimpleHandler, } from './ask-user-tools.js';
11
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';
12
+ export type { Flow, FlowTone, Node as FlowNode, QuestionNode, InfoNode, BranchNode, SummaryNode, QuestionInput, Choice, Proposal, NextRef, BranchRoute, BranchCondition, NodeId, IconName, Tint, RenderVariant, AnswerValue, AbortReason, InteractiveFlowInput, InteractiveFlowResult, InteractiveFlowHandler, FlowValidationResult, FlowValidationError, FlowErrorCode, } from './interactive-flow-tool.js';
@@ -143,15 +143,39 @@ export interface InteractiveFlowInput {
143
143
  }
144
144
  /** Value collected from a node (Question only — info/branch/summary don't produce values) */
145
145
  export type AnswerValue = string | string[];
146
+ /** Why the flow ended without completion */
147
+ export type AbortReason =
148
+ /** User pressed Escape */
149
+ 'escape'
150
+ /** User clicked the X (close) button */
151
+ | 'close-button'
152
+ /** User clicked outside the modal */
153
+ | 'backdrop'
154
+ /** Conversation tab closed or project switched mid-flow */
155
+ | 'conversation-closed'
156
+ /** Catch-all (renderer not destroyed, but no explicit reason recorded) */
157
+ | 'unknown';
146
158
  export interface InteractiveFlowResult {
147
159
  /** False if the user aborted (closed modal, switched conversation, etc.) */
148
160
  completed: boolean;
149
161
  /** Every node visited, in order — including backtracked nodes */
150
162
  path: NodeId[];
151
- /** Final answers — answers downstream of a backtrack point are wiped */
163
+ /**
164
+ * Final answers (IDs / strings — what you compare in `BranchCondition`).
165
+ * Wiped downstream of any backtrack point.
166
+ */
152
167
  answers: Record<NodeId, AnswerValue>;
168
+ /**
169
+ * Human-readable labels for the answers above — use this when summarizing
170
+ * the user's choices back to them. For text-mode answers, label == value.
171
+ * For single/multi/proposal modes, this is the visible label the user saw.
172
+ */
173
+ answerLabels: Record<NodeId, AnswerValue>;
153
174
  /** Present iff !completed — the node where the user aborted */
154
175
  abortedAt?: NodeId;
176
+ /** Present iff !completed — how the abort happened (helps the agent
177
+ * differentiate "user closed the modal" from "tab was closed externally") */
178
+ abortReason?: AbortReason;
155
179
  /** Wall-clock time the user spent */
156
180
  durationMs: number;
157
181
  /** Validator warnings that didn't block execution (e.g., orphan nodes) */
@@ -160,7 +184,7 @@ export interface InteractiveFlowResult {
160
184
  /** UI handler — the consumer provides this; the SDK calls it once validation passes */
161
185
  export type InteractiveFlowHandler = (input: InteractiveFlowInput) => Promise<InteractiveFlowResult>;
162
186
  /** 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';
187
+ export type FlowErrorCode = 'MISSING_START_NODE' | 'DUPLICATE_NODE_ID' | 'UNRESOLVED_NEXT' | 'UNRESOLVED_BRANCH' | 'INVALID_ICON' | 'INVALID_RENDERER' | 'INVALID_NODE_TYPE' | 'INVALID_CONDITION' | 'CYCLE_DETECTED';
164
188
  export interface FlowValidationError {
165
189
  code: FlowErrorCode;
166
190
  message: string;
@@ -108,7 +108,20 @@ export function validateFlow(flowInput) {
108
108
  continue; // already flagged above
109
109
  validateNode(node, flow.nodes, errors);
110
110
  }
111
- // 4. Reachabilitywarn on orphans
111
+ // 4. Cycle detection any cycle is an error, not a warning. A cycle means
112
+ // the user can be trapped in an infinite loop ("Yes → q1 → Yes → q1 ..."),
113
+ // which is fundamentally broken UX even though the user CAN escape via Cancel.
114
+ if (errors.length === 0) {
115
+ const cycles = detectCycles(flow);
116
+ for (const cycle of cycles) {
117
+ errors.push({
118
+ code: 'CYCLE_DETECTED',
119
+ message: `Cycle detected: ${cycle.join(' → ')}. Flows must be acyclic — a user navigating the cycle would loop forever (with Cancel as the only escape). If you need a "retry" pattern, model it with explicit nodes that ask "Try again?" before re-entering.`,
120
+ nodeId: cycle[0],
121
+ });
122
+ }
123
+ }
124
+ // 5. Reachability — warn on orphans (only when no errors)
112
125
  if (errors.length === 0 && flow.startNode in flow.nodes) {
113
126
  const reachable = computeReachable(flow);
114
127
  for (const id of nodeIds) {
@@ -437,6 +450,120 @@ function checkIcon(icon, nodeId, errors) {
437
450
  });
438
451
  }
439
452
  }
453
+ /**
454
+ * Detect cycles in the flow graph. Returns an array of cycles, where each
455
+ * cycle is an array of node IDs representing the loop (first node repeated
456
+ * implicitly — `[a, b, c]` means `a → b → c → a`).
457
+ *
458
+ * Uses iterative DFS with explicit recursion-stack tracking ("color marking"):
459
+ * - WHITE = not visited yet
460
+ * - GRAY = currently in the recursion stack (on the active path)
461
+ * - BLACK = fully explored
462
+ * If we encounter a GRAY node, that's a back-edge → cycle.
463
+ *
464
+ * Walks every outgoing edge (next, byAnswer, branch routes, branch default)
465
+ * so cycles through any path are caught.
466
+ */
467
+ function detectCycles(flow) {
468
+ const cycles = [];
469
+ const seenCycles = new Set(); // dedup by sorted member set
470
+ const color = new Map();
471
+ const allIds = Object.keys(flow.nodes);
472
+ function getOutgoing(node) {
473
+ if (node.type === 'summary')
474
+ return [];
475
+ if (node.type === 'branch') {
476
+ const t = [];
477
+ const routes = node.routes;
478
+ if (Array.isArray(routes)) {
479
+ for (const r of routes) {
480
+ if (r && typeof r.goto === 'string')
481
+ t.push(r.goto);
482
+ }
483
+ }
484
+ if (typeof node.default === 'string')
485
+ t.push(node.default);
486
+ return t;
487
+ }
488
+ // question | info — extract from next
489
+ const next = node.next;
490
+ if (typeof next === 'string')
491
+ return [next];
492
+ if (!next || typeof next !== 'object')
493
+ return [];
494
+ const t = [];
495
+ if (next.byAnswer && typeof next.byAnswer === 'object') {
496
+ for (const v of Object.values(next.byAnswer)) {
497
+ if (typeof v === 'string')
498
+ t.push(v);
499
+ }
500
+ }
501
+ if (typeof next.default === 'string')
502
+ t.push(next.default);
503
+ return t;
504
+ }
505
+ function recordCycle(path, backEdgeTarget) {
506
+ const startIdx = path.indexOf(backEdgeTarget);
507
+ if (startIdx === -1)
508
+ return;
509
+ const cycle = path.slice(startIdx);
510
+ // Dedup by canonicalizing — rotate to smallest member first, then stringify
511
+ const canonical = canonicalCycle(cycle);
512
+ if (seenCycles.has(canonical))
513
+ return;
514
+ seenCycles.add(canonical);
515
+ cycles.push(cycle);
516
+ }
517
+ for (const startId of allIds) {
518
+ if (color.get(startId))
519
+ continue;
520
+ const stack = [
521
+ { nodeId: startId, targets: getOutgoing(flow.nodes[startId]), cursor: 0 },
522
+ ];
523
+ color.set(startId, 'gray');
524
+ while (stack.length > 0) {
525
+ const frame = stack[stack.length - 1];
526
+ if (frame.cursor >= frame.targets.length) {
527
+ // Done with this node — mark black, pop
528
+ color.set(frame.nodeId, 'black');
529
+ stack.pop();
530
+ continue;
531
+ }
532
+ const target = frame.targets[frame.cursor++];
533
+ if (!(target in flow.nodes))
534
+ continue; // missing node (UNRESOLVED_NEXT already flagged)
535
+ const status = color.get(target);
536
+ if (status === 'gray') {
537
+ // Back-edge → cycle
538
+ const activePath = stack.map((f) => f.nodeId);
539
+ recordCycle(activePath, target);
540
+ continue;
541
+ }
542
+ if (status === 'black')
543
+ continue; // already fully explored
544
+ // WHITE — recurse
545
+ color.set(target, 'gray');
546
+ stack.push({ nodeId: target, targets: getOutgoing(flow.nodes[target]), cursor: 0 });
547
+ }
548
+ }
549
+ return cycles;
550
+ }
551
+ /**
552
+ * Canonical-form a cycle so `[a,b,c]`, `[b,c,a]`, `[c,a,b]` all collapse to
553
+ * the same string. Used for dedup.
554
+ */
555
+ function canonicalCycle(cycle) {
556
+ if (cycle.length === 0)
557
+ return '';
558
+ // Find rotation starting with lexicographically-smallest node
559
+ let minIdx = 0;
560
+ for (let i = 1; i < cycle.length; i++) {
561
+ if (cycle[i] < cycle[minIdx])
562
+ minIdx = i;
563
+ }
564
+ const rotated = [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
565
+ return rotated.join('→');
566
+ }
440
567
  /** Compute set of node IDs reachable from startNode via any path */
441
568
  function computeReachable(flow) {
442
569
  const reachable = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",