@compilr-dev/sdk 0.10.32 → 0.10.34

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.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Flow Runner — Public API.
3
+ *
4
+ * Pure state-machine helpers for the `build_interactive_flow` DSL.
5
+ * Hosts hold the runtime state and call these helpers for transitions
6
+ * (next-resolution, branch evaluation, backtrack). The helpers are
7
+ * pure functions with no framework dependencies.
8
+ *
9
+ * See `runner.ts` for the implementation rationale and spec link.
10
+ */
11
+ export { resolveNext, evaluateBranchCondition, walkPastBranches, lookupLabel, applyBacktrack, } from './runner.js';
12
+ export type { BacktrackResult } from './runner.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Flow Runner — Public API.
3
+ *
4
+ * Pure state-machine helpers for the `build_interactive_flow` DSL.
5
+ * Hosts hold the runtime state and call these helpers for transitions
6
+ * (next-resolution, branch evaluation, backtrack). The helpers are
7
+ * pure functions with no framework dependencies.
8
+ *
9
+ * See `runner.ts` for the implementation rationale and spec link.
10
+ */
11
+ export { resolveNext, evaluateBranchCondition, walkPastBranches, lookupLabel, applyBacktrack, } from './runner.js';
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Flow Runner — Pure state-machine helpers for `build_interactive_flow`.
3
+ *
4
+ * Hosts (Desktop, CLI) hold the runtime state in whatever container fits
5
+ * their UI framework (React useState, BaseOverlayV2 state object, etc.)
6
+ * and call these helpers to compute transitions. The helpers are
7
+ * intentionally pure — no React, no DOM, no I/O — so they're trivially
8
+ * testable and reusable across renderers.
9
+ *
10
+ * Code lifted verbatim from `compilr-dev-desktop/src/renderer/src/
11
+ * components/flow/state/useFlowState.ts:297-409` (lines 240-272 also
12
+ * inform `applyBacktrack`). Desktop's `useFlowState` should re-export
13
+ * these in a follow-up commit; until then it keeps its local copies
14
+ * and these definitions are the canonical SDK ones.
15
+ *
16
+ * Spec: project-docs/00-requirements/compilr-dev-cli/interactive-flow-cli-spec.md
17
+ */
18
+ import type { AnswerValue, BranchCondition, Flow, NextRef, NodeId, QuestionNode } from '../tools/interactive-flow-tool.js';
19
+ /**
20
+ * Result of an `applyBacktrack` call: the new path + the answer maps
21
+ * with everything-after-the-restored-node wiped (per spec §4).
22
+ */
23
+ export interface BacktrackResult {
24
+ path: NodeId[];
25
+ answers: Record<NodeId, AnswerValue>;
26
+ answerLabels: Record<NodeId, AnswerValue>;
27
+ }
28
+ /**
29
+ * Resolve a `NextRef` (+ optional answer that was just collected) to the
30
+ * single target nodeId. Returns `undefined` when the ref can't resolve —
31
+ * caller treats that as malformed-flow.
32
+ *
33
+ * Behaviour:
34
+ * - String ref: returned verbatim.
35
+ * - `{ byAnswer: { ... }, default }`:
36
+ * - For a scalar answer, look it up in `byAnswer`.
37
+ * - For a multi-select array, the first matching key wins.
38
+ * - Fall back to `default` if nothing matched.
39
+ */
40
+ export declare function resolveNext(next: NextRef, answer?: AnswerValue): NodeId | undefined;
41
+ /**
42
+ * Evaluate a single branch condition against the collected answer map.
43
+ * Returns true iff the condition matches.
44
+ *
45
+ * - `equals`: scalar string equality
46
+ * - `includes`:
47
+ * - scalar string: equality (treat the answer as a 1-element list)
48
+ * - array (multi): true if the array contains the value
49
+ * - `notEquals`: scalar string inequality
50
+ */
51
+ export declare function evaluateBranchCondition(condition: BranchCondition, answers: Record<NodeId, AnswerValue>): boolean;
52
+ /**
53
+ * Walk past any leading branch nodes from the tip of `path`, appending
54
+ * each branch visited and finally appending the next non-branch node
55
+ * we land on. Returns the new path.
56
+ *
57
+ * The branch nodes ARE recorded in path (so the agent sees the routing
58
+ * trail), but they're never the current node when this function returns
59
+ * — the caller can safely render `path[path.length - 1]`.
60
+ *
61
+ * Safety: bounded by `Object.keys(flow.nodes).length + 1` to prevent
62
+ * infinite loops on malformed cyclic branches (SDK `validateFlow`
63
+ * should prevent this, but defensive).
64
+ */
65
+ export declare function walkPastBranches(flow: Flow, path: NodeId[], answers: Record<NodeId, AnswerValue>): NodeId[];
66
+ /**
67
+ * Given a question node and the chosen answer, return the human-readable
68
+ * label(s). For `text` mode the label IS the answer. For `single` and
69
+ * `proposal`, look up the choice/option with the matching id. For
70
+ * `multi`, return an array of labels.
71
+ *
72
+ * Falls back to the answer id itself if no matching choice is found —
73
+ * keeps `answerLabels` populated even when validation didn't catch a
74
+ * mismatch.
75
+ */
76
+ export declare function lookupLabel(node: QuestionNode, answer: AnswerValue): AnswerValue;
77
+ /**
78
+ * Compute the result of a backtrack ("go back one user-visible step").
79
+ *
80
+ * Pops path entries until the previous question/info node is exposed,
81
+ * then wipes every answer (and label) recorded at or after that node
82
+ * — per spec §4: "answers wiped downstream of any backtrack point".
83
+ *
84
+ * Returns `null` when there's no prior user-visible node (i.e. the
85
+ * user is already on `startNode`); the caller should treat that as a
86
+ * no-op (or, depending on UI, as a cancel signal).
87
+ */
88
+ export declare function applyBacktrack(flow: Flow, path: NodeId[], answers: Record<NodeId, AnswerValue>, answerLabels: Record<NodeId, AnswerValue>): BacktrackResult | null;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Flow Runner — Pure state-machine helpers for `build_interactive_flow`.
3
+ *
4
+ * Hosts (Desktop, CLI) hold the runtime state in whatever container fits
5
+ * their UI framework (React useState, BaseOverlayV2 state object, etc.)
6
+ * and call these helpers to compute transitions. The helpers are
7
+ * intentionally pure — no React, no DOM, no I/O — so they're trivially
8
+ * testable and reusable across renderers.
9
+ *
10
+ * Code lifted verbatim from `compilr-dev-desktop/src/renderer/src/
11
+ * components/flow/state/useFlowState.ts:297-409` (lines 240-272 also
12
+ * inform `applyBacktrack`). Desktop's `useFlowState` should re-export
13
+ * these in a follow-up commit; until then it keeps its local copies
14
+ * and these definitions are the canonical SDK ones.
15
+ *
16
+ * Spec: project-docs/00-requirements/compilr-dev-cli/interactive-flow-cli-spec.md
17
+ */
18
+ // =============================================================================
19
+ // Pure helpers
20
+ // =============================================================================
21
+ /**
22
+ * Resolve a `NextRef` (+ optional answer that was just collected) to the
23
+ * single target nodeId. Returns `undefined` when the ref can't resolve —
24
+ * caller treats that as malformed-flow.
25
+ *
26
+ * Behaviour:
27
+ * - String ref: returned verbatim.
28
+ * - `{ byAnswer: { ... }, default }`:
29
+ * - For a scalar answer, look it up in `byAnswer`.
30
+ * - For a multi-select array, the first matching key wins.
31
+ * - Fall back to `default` if nothing matched.
32
+ */
33
+ export function resolveNext(next, answer) {
34
+ if (typeof next === 'string')
35
+ return next;
36
+ // Defensive against malformed runtime input (the test fixture passes
37
+ // null/undefined). TS proves this can't happen if you respect the
38
+ // declared type, but consumers may not.
39
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
40
+ if (!next || typeof next !== 'object')
41
+ return undefined;
42
+ // byAnswer map
43
+ const answerKey = typeof answer === 'string' ? answer : undefined;
44
+ if (answerKey !== undefined && answerKey in next.byAnswer) {
45
+ return next.byAnswer[answerKey];
46
+ }
47
+ // multi-select answer — first matching key wins
48
+ if (Array.isArray(answer)) {
49
+ for (const a of answer) {
50
+ if (a in next.byAnswer)
51
+ return next.byAnswer[a];
52
+ }
53
+ }
54
+ return next.default;
55
+ }
56
+ /**
57
+ * Evaluate a single branch condition against the collected answer map.
58
+ * Returns true iff the condition matches.
59
+ *
60
+ * - `equals`: scalar string equality
61
+ * - `includes`:
62
+ * - scalar string: equality (treat the answer as a 1-element list)
63
+ * - array (multi): true if the array contains the value
64
+ * - `notEquals`: scalar string inequality
65
+ */
66
+ export function evaluateBranchCondition(condition, answers) {
67
+ const value = answers[condition.questionId];
68
+ // Defensive — Record<K,V>[K] is `V` (not `V|undefined`) in non-strict-
69
+ // index mode, but the question may simply not have been answered yet.
70
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
71
+ if (value === undefined)
72
+ return false;
73
+ if ('equals' in condition) {
74
+ return typeof value === 'string' && value === condition.equals;
75
+ }
76
+ if ('includes' in condition) {
77
+ if (typeof value === 'string')
78
+ return value === condition.includes;
79
+ return value.includes(condition.includes);
80
+ }
81
+ if ('notEquals' in condition) {
82
+ return typeof value === 'string' && value !== condition.notEquals;
83
+ }
84
+ return false;
85
+ }
86
+ /**
87
+ * Walk past any leading branch nodes from the tip of `path`, appending
88
+ * each branch visited and finally appending the next non-branch node
89
+ * we land on. Returns the new path.
90
+ *
91
+ * The branch nodes ARE recorded in path (so the agent sees the routing
92
+ * trail), but they're never the current node when this function returns
93
+ * — the caller can safely render `path[path.length - 1]`.
94
+ *
95
+ * Safety: bounded by `Object.keys(flow.nodes).length + 1` to prevent
96
+ * infinite loops on malformed cyclic branches (SDK `validateFlow`
97
+ * should prevent this, but defensive).
98
+ */
99
+ export function walkPastBranches(flow, path, answers) {
100
+ const result = [...path];
101
+ let guard = Object.keys(flow.nodes).length + 1;
102
+ while (guard-- > 0) {
103
+ const tip = result[result.length - 1];
104
+ const node = flow.nodes[tip];
105
+ // Defensive — `flow.nodes[tip]` is typed as `Node` but the runtime
106
+ // value may be undefined if a NextRef pointed to a non-existent id.
107
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
108
+ if (!node || node.type !== 'branch')
109
+ return result;
110
+ // Evaluate routes in order; first match wins
111
+ let target = node.default;
112
+ for (const route of node.routes) {
113
+ if (evaluateBranchCondition(route.when, answers)) {
114
+ target = route.goto;
115
+ break;
116
+ }
117
+ }
118
+ if (!target || !(target in flow.nodes))
119
+ return result; // malformed; stop walking
120
+ result.push(target);
121
+ }
122
+ return result;
123
+ }
124
+ /**
125
+ * Given a question node and the chosen answer, return the human-readable
126
+ * label(s). For `text` mode the label IS the answer. For `single` and
127
+ * `proposal`, look up the choice/option with the matching id. For
128
+ * `multi`, return an array of labels.
129
+ *
130
+ * Falls back to the answer id itself if no matching choice is found —
131
+ * keeps `answerLabels` populated even when validation didn't catch a
132
+ * mismatch.
133
+ */
134
+ export function lookupLabel(node, answer) {
135
+ const mode = node.input.mode;
136
+ if (mode === 'text') {
137
+ return answer;
138
+ }
139
+ if (mode === 'single') {
140
+ const id = typeof answer === 'string' ? answer : answer[0];
141
+ // Defensive — empty array answer
142
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
143
+ if (id === undefined)
144
+ return answer;
145
+ const found = node.input.choices.find((c) => c.id === id);
146
+ return found?.label ?? id;
147
+ }
148
+ if (mode === 'multi') {
149
+ const ids = Array.isArray(answer) ? answer : [answer];
150
+ return ids.map((id) => {
151
+ if (node.input.mode !== 'multi')
152
+ return id;
153
+ const found = node.input.choices.find((c) => c.id === id);
154
+ return found?.label ?? id;
155
+ });
156
+ }
157
+ // mode === 'proposal' — narrowed by the if-chain above
158
+ const id = typeof answer === 'string' ? answer : answer[0];
159
+ // Defensive — empty array answer
160
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
161
+ if (id === undefined)
162
+ return answer;
163
+ const found = node.input.options.find((o) => o.id === id);
164
+ return found?.label ?? id;
165
+ }
166
+ /**
167
+ * Compute the result of a backtrack ("go back one user-visible step").
168
+ *
169
+ * Pops path entries until the previous question/info node is exposed,
170
+ * then wipes every answer (and label) recorded at or after that node
171
+ * — per spec §4: "answers wiped downstream of any backtrack point".
172
+ *
173
+ * Returns `null` when there's no prior user-visible node (i.e. the
174
+ * user is already on `startNode`); the caller should treat that as a
175
+ * no-op (or, depending on UI, as a cancel signal).
176
+ */
177
+ export function applyBacktrack(flow, path, answers, answerLabels) {
178
+ let targetIdx = path.length - 1;
179
+ for (let i = path.length - 2; i >= 0; i--) {
180
+ const id = path[i];
181
+ const node = flow.nodes[id];
182
+ // Defensive against missing-node entries in path (shouldn't happen but
183
+ // path entries originate from runtime, not the type system)
184
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
185
+ if (node && node.type !== 'branch') {
186
+ targetIdx = i;
187
+ break;
188
+ }
189
+ }
190
+ if (targetIdx === path.length - 1)
191
+ return null; // no prior visible node
192
+ const newPath = path.slice(0, targetIdx + 1);
193
+ // Wipe answers (and labels) for nodes from targetIdx onward (inclusive —
194
+ // the user is re-entering that node). Build fresh maps rather than
195
+ // `delete` keys (lint forbids dynamic delete).
196
+ const wipedIds = new Set(path.slice(targetIdx));
197
+ const wipedAnswers = {};
198
+ for (const [id, val] of Object.entries(answers)) {
199
+ if (!wipedIds.has(id))
200
+ wipedAnswers[id] = val;
201
+ }
202
+ const wipedLabels = {};
203
+ for (const [id, val] of Object.entries(answerLabels)) {
204
+ if (!wipedIds.has(id))
205
+ wipedLabels[id] = val;
206
+ }
207
+ return {
208
+ path: newPath,
209
+ answers: wipedAnswers,
210
+ answerLabels: wipedLabels,
211
+ };
212
+ }
package/dist/index.d.ts CHANGED
@@ -84,6 +84,8 @@ export type { PermissionRule, PermissionMode, PermissionLevel } from './permissi
84
84
  export { DEFAULT_DELEGATION_CONFIG } from './delegation.js';
85
85
  export { FileEpisodeStore, EpisodeRecorder, isSignificantWork, extractAffectedFiles, extractLinesChanged, queryWorkAtRisk, buildWorkSummaryContent, updateWorkSummaryAnchor, } from './episodes/index.js';
86
86
  export type { FileEpisodeStoreOptions, EpisodeRecorderConfig, WorkSummaryAnchorConfig, PendingToolSignal, EpisodeFile, } from './episodes/index.js';
87
+ export { resolveNext, evaluateBranchCondition, walkPastBranches, lookupLabel, applyBacktrack, } from './flow-runner/index.js';
88
+ export type { BacktrackResult } from './flow-runner/index.js';
87
89
  export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, } from './mcp-config.js';
88
90
  export type { MCPServerEntry, MCPConfigFile, ResolvedMCPServer } from './mcp-config.js';
89
91
  export { generateProject, isGitConfigured, generateCompilrMd, generateConfigJson, generateReadmeMd, generateCodingStandardsMd, generatePackageJson, generateTsconfig, generateGitignore, generateCompilrMdForImport, detectProjectInfo, detectGitInfo, prettifyName, getLanguageLabel, getFrameworkLabel, validateImportPath, isValidProjectName, projectExists, TECH_STACK_LABELS, CODING_STANDARDS_LABELS, REPO_PATTERN_LABELS, WORKFLOW_VERSION, } from './project-generator/index.js';
package/dist/index.js CHANGED
@@ -206,6 +206,10 @@ export { DEFAULT_DELEGATION_CONFIG } from './delegation.js';
206
206
  // =============================================================================
207
207
  export { FileEpisodeStore, EpisodeRecorder, isSignificantWork, extractAffectedFiles, extractLinesChanged, queryWorkAtRisk, buildWorkSummaryContent, updateWorkSummaryAnchor, } from './episodes/index.js';
208
208
  // =============================================================================
209
+ // Flow Runner — `build_interactive_flow` State Machine Helpers
210
+ // =============================================================================
211
+ export { resolveNext, evaluateBranchCondition, walkPastBranches, lookupLabel, applyBacktrack, } from './flow-runner/index.js';
212
+ // =============================================================================
209
213
  // Shared MCP Configuration
210
214
  // =============================================================================
211
215
  export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, } from './mcp-config.js';
@@ -86,13 +86,35 @@ export function createAskUserTool(handler) {
86
86
  },
87
87
  execute: async (input) => {
88
88
  try {
89
- if (input.questions.length === 0) {
89
+ // Defensive — some providers (Gemini Flash via OpenAI compat, etc.)
90
+ // ship nested object arguments as JSON-stringified strings. Parse
91
+ // before the length check so hosts always see a real array.
92
+ let questions = input.questions;
93
+ if (typeof questions === 'string') {
94
+ try {
95
+ questions = JSON.parse(questions);
96
+ }
97
+ catch {
98
+ return {
99
+ success: false,
100
+ error: 'questions must be an array (received a non-JSON string)',
101
+ };
102
+ }
103
+ }
104
+ if (!Array.isArray(questions)) {
105
+ return { success: false, error: 'questions must be an array' };
106
+ }
107
+ if (questions.length === 0) {
90
108
  return { success: false, error: 'At least one question is required' };
91
109
  }
92
- if (input.questions.length > 5) {
110
+ if (questions.length > 5) {
93
111
  return { success: false, error: 'Maximum 5 questions allowed' };
94
112
  }
95
- const result = await handler(input);
113
+ const normalised = {
114
+ ...input,
115
+ questions: questions,
116
+ };
117
+ const result = await handler(normalised);
96
118
  return { success: true, result };
97
119
  }
98
120
  catch (err) {
@@ -706,7 +706,29 @@ export function createInteractiveFlowTool(handler) {
706
706
  inputSchema: INTERACTIVE_FLOW_INPUT_SCHEMA,
707
707
  execute: async (input) => {
708
708
  try {
709
- const validation = validateFlow(input.flow);
709
+ // Defensive some providers (Gemini Flash via OpenAI compat,
710
+ // etc.) ship complex object arguments as JSON-stringified strings
711
+ // rather than parsed objects. The agents library does the outer
712
+ // parse but doesn't recurse into nested values. Parse here so the
713
+ // validator sees the proper object shape. Same quirk hit by
714
+ // ask_user.questions and propose_alternatives.alternatives —
715
+ // fixed at the CLI display layer for those, but here it must
716
+ // happen pre-validation since the validator rejects strings.
717
+ let flow = input.flow;
718
+ if (typeof flow === 'string') {
719
+ try {
720
+ flow = JSON.parse(flow);
721
+ }
722
+ catch {
723
+ return {
724
+ success: false,
725
+ error: 'Interactive flow validation failed:\n' +
726
+ "[INVALID_NODE_TYPE] Flow must be an object — received a string that wasn't valid JSON. " +
727
+ 'Pass the flow as a parsed object, not as a JSON-stringified string.',
728
+ };
729
+ }
730
+ }
731
+ const validation = validateFlow(flow);
710
732
  if (!validation.ok) {
711
733
  const summary = validation.errors
712
734
  .map((e) => `[${e.code}]${e.nodeId ? ` (node '${e.nodeId}')` : ''} ${e.message}`)
@@ -716,7 +738,11 @@ export function createInteractiveFlowTool(handler) {
716
738
  error: `Interactive flow validation failed:\n${summary}`,
717
739
  };
718
740
  }
719
- const result = await handler(input);
741
+ // Validation passed flow is a well-formed Flow. Hand it to the
742
+ // handler with the (possibly-parsed) value so the host gets an
743
+ // object too.
744
+ const normalisedInput = { flow: flow };
745
+ const result = await handler(normalisedInput);
720
746
  if (validation.warnings.length > 0) {
721
747
  result.warnings = [...(result.warnings ?? []), ...validation.warnings];
722
748
  }
@@ -88,13 +88,35 @@ export function createProposeAlternativesTool(handler) {
88
88
  },
89
89
  execute: async (input) => {
90
90
  try {
91
- if (input.alternatives.length < 2) {
91
+ // Defensive — some providers (Gemini Flash via OpenAI compat, etc.)
92
+ // ship nested object arguments as JSON-stringified strings. Parse
93
+ // before validation so hosts always see a real array.
94
+ let alternatives = input.alternatives;
95
+ if (typeof alternatives === 'string') {
96
+ try {
97
+ alternatives = JSON.parse(alternatives);
98
+ }
99
+ catch {
100
+ return {
101
+ success: false,
102
+ error: 'alternatives must be an array (received a non-JSON string)',
103
+ };
104
+ }
105
+ }
106
+ if (!Array.isArray(alternatives)) {
107
+ return { success: false, error: 'alternatives must be an array' };
108
+ }
109
+ if (alternatives.length < 2) {
92
110
  return { success: false, error: 'At least 2 alternatives are required' };
93
111
  }
94
- if (input.alternatives.length > 3) {
112
+ if (alternatives.length > 3) {
95
113
  return { success: false, error: 'Maximum 3 alternatives allowed' };
96
114
  }
97
- const result = await handler(input);
115
+ const normalised = {
116
+ ...input,
117
+ alternatives: alternatives,
118
+ };
119
+ const result = await handler(normalised);
98
120
  if (result.rejected) {
99
121
  return {
100
122
  success: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.10.32",
3
+ "version": "0.10.34",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",