@compilr-dev/sdk 0.10.31 → 0.10.33

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,19 @@
1
+ /**
2
+ * Episodes Module
3
+ *
4
+ * Work history tracking — file-based persistence + automatic
5
+ * recording via an AfterToolHook. Both CLI and Desktop consume these
6
+ * exports; the global episode store helpers (setGlobalEpisodeStore /
7
+ * getGlobalEpisodeStore) intentionally remain CLI-side because the
8
+ * "global singleton" pattern is CLI-specific. Desktop wires the store
9
+ * via direct dependency injection through the IEpisodeService.
10
+ */
11
+ export { FileEpisodeStore } from './store.js';
12
+ export type { FileEpisodeStoreOptions } from './store.js';
13
+ export { EpisodeRecorder } from './recorder.js';
14
+ export type { EpisodeRecorderConfig } from './recorder.js';
15
+ export { isSignificantWork, extractAffectedFiles, extractLinesChanged, } from './significant-work.js';
16
+ export { queryWorkAtRisk } from './work-at-risk.js';
17
+ export { buildWorkSummaryContent, updateWorkSummaryAnchor } from './work-summary-anchor.js';
18
+ export type { WorkSummaryAnchorConfig } from './work-summary-anchor.js';
19
+ export type { PendingToolSignal, EpisodeFile } from './types.js';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Episodes Module
3
+ *
4
+ * Work history tracking — file-based persistence + automatic
5
+ * recording via an AfterToolHook. Both CLI and Desktop consume these
6
+ * exports; the global episode store helpers (setGlobalEpisodeStore /
7
+ * getGlobalEpisodeStore) intentionally remain CLI-side because the
8
+ * "global singleton" pattern is CLI-specific. Desktop wires the store
9
+ * via direct dependency injection through the IEpisodeService.
10
+ */
11
+ // Store
12
+ export { FileEpisodeStore } from './store.js';
13
+ // Recorder
14
+ export { EpisodeRecorder } from './recorder.js';
15
+ // Significant work detection
16
+ export { isSignificantWork, extractAffectedFiles, extractLinesChanged, } from './significant-work.js';
17
+ // Work-at-risk query (used by guardrails / rehearsal)
18
+ export { queryWorkAtRisk } from './work-at-risk.js';
19
+ // Work summary anchor (auto-updates a session anchor with cumulative work)
20
+ export { buildWorkSummaryContent, updateWorkSummaryAnchor } from './work-summary-anchor.js';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Episode Recorder
3
+ *
4
+ * Collects tool call signals via an AfterToolHook, batches them within
5
+ * a time window, and persists as WorkEpisode records.
6
+ */
7
+ import type { AfterToolHook, Effort, EpisodeStore } from '@compilr-dev/agents';
8
+ export interface EpisodeRecorderConfig {
9
+ /** Episode store for persistence */
10
+ store: EpisodeStore;
11
+ /** Agent ID (e.g., 'default', 'backend') */
12
+ agentId: string;
13
+ /** Terminal/window session prefix (first 8 chars). Hosts that don't
14
+ * have a terminal can pass any short stable identifier (e.g. window id). */
15
+ terminalPrefix: string;
16
+ /** Session ID for grouping */
17
+ sessionId: string;
18
+ /** Enable automatic recording via hook (default: true) */
19
+ autoRecord?: boolean;
20
+ /** Minimum effort level to record (default: 'low') */
21
+ minEffortToRecord?: Effort;
22
+ /** Batch window in milliseconds (default: 5000) */
23
+ batchWindowMs?: number;
24
+ /** Called after each batch is flushed as a WorkEpisode */
25
+ onBatchFlushed?: () => void;
26
+ }
27
+ export declare class EpisodeRecorder {
28
+ private readonly store;
29
+ private readonly agentId;
30
+ private readonly terminalPrefix;
31
+ private readonly sessionId;
32
+ private readonly autoRecord;
33
+ private readonly minEffortToRecord;
34
+ private readonly batchWindowMs;
35
+ private readonly onBatchFlushed?;
36
+ private pendingSignals;
37
+ private batchTimer;
38
+ private batchStartTime;
39
+ constructor(config: EpisodeRecorderConfig);
40
+ /**
41
+ * Create an AfterToolHook for observing tool calls.
42
+ * Always returns undefined (observation only — never modifies results).
43
+ */
44
+ createHook(): AfterToolHook;
45
+ /**
46
+ * Force-flush all pending signals as a WorkEpisode.
47
+ * Called on shutdown to avoid losing data.
48
+ */
49
+ flush(): void;
50
+ private scheduleBatch;
51
+ private buildAndSaveEpisode;
52
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Episode Recorder
3
+ *
4
+ * Collects tool call signals via an AfterToolHook, batches them within
5
+ * a time window, and persists as WorkEpisode records.
6
+ */
7
+ import { randomUUID } from 'crypto';
8
+ import { estimateEffort, EFFORT_ORDER } from '@compilr-dev/agents';
9
+ import { isSignificantWork, extractAffectedFiles, extractLinesChanged, } from './significant-work.js';
10
+ // =============================================================================
11
+ // Constants
12
+ // =============================================================================
13
+ const DEFAULT_BATCH_WINDOW_MS = 5000;
14
+ const DEFAULT_MIN_EFFORT = 'low';
15
+ // =============================================================================
16
+ // Implementation
17
+ // =============================================================================
18
+ export class EpisodeRecorder {
19
+ store;
20
+ agentId;
21
+ terminalPrefix;
22
+ sessionId;
23
+ autoRecord;
24
+ minEffortToRecord;
25
+ batchWindowMs;
26
+ onBatchFlushed;
27
+ pendingSignals = [];
28
+ batchTimer = null;
29
+ batchStartTime = null;
30
+ constructor(config) {
31
+ this.store = config.store;
32
+ this.agentId = config.agentId;
33
+ this.terminalPrefix = config.terminalPrefix;
34
+ this.sessionId = config.sessionId;
35
+ this.autoRecord = config.autoRecord ?? true;
36
+ this.minEffortToRecord = config.minEffortToRecord ?? DEFAULT_MIN_EFFORT;
37
+ this.batchWindowMs = config.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS;
38
+ this.onBatchFlushed = config.onBatchFlushed;
39
+ }
40
+ /**
41
+ * Create an AfterToolHook for observing tool calls.
42
+ * Always returns undefined (observation only — never modifies results).
43
+ */
44
+ createHook() {
45
+ return (context) => {
46
+ if (!this.autoRecord)
47
+ return undefined;
48
+ const success = context.result.success;
49
+ if (!isSignificantWork(context.toolName, context.input, success)) {
50
+ return undefined;
51
+ }
52
+ const signal = {
53
+ toolName: context.toolName,
54
+ files: extractAffectedFiles(context.toolName, context.input),
55
+ linesChanged: extractLinesChanged(context.result),
56
+ durationMs: context.durationMs,
57
+ success,
58
+ timestamp: new Date().toISOString(),
59
+ };
60
+ this.pendingSignals.push(signal);
61
+ this.scheduleBatch();
62
+ return undefined;
63
+ };
64
+ }
65
+ /**
66
+ * Force-flush all pending signals as a WorkEpisode.
67
+ * Called on shutdown to avoid losing data.
68
+ */
69
+ flush() {
70
+ if (this.batchTimer) {
71
+ clearTimeout(this.batchTimer);
72
+ this.batchTimer = null;
73
+ }
74
+ this.buildAndSaveEpisode();
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // Internal
78
+ // ---------------------------------------------------------------------------
79
+ scheduleBatch() {
80
+ if (!this.batchStartTime) {
81
+ this.batchStartTime = Date.now();
82
+ }
83
+ // Clear existing timer and set a new one
84
+ if (this.batchTimer) {
85
+ clearTimeout(this.batchTimer);
86
+ }
87
+ this.batchTimer = setTimeout(() => {
88
+ this.batchTimer = null;
89
+ this.buildAndSaveEpisode();
90
+ }, this.batchWindowMs);
91
+ }
92
+ buildAndSaveEpisode() {
93
+ if (this.pendingSignals.length === 0) {
94
+ this.batchStartTime = null;
95
+ return;
96
+ }
97
+ const signals = [...this.pendingSignals];
98
+ this.pendingSignals = [];
99
+ const startTime = this.batchStartTime;
100
+ this.batchStartTime = null;
101
+ // Collect unique files, tool names, total lines
102
+ const allFiles = new Set();
103
+ const toolNames = new Set();
104
+ let totalLines = 0;
105
+ let totalDurationMs = 0;
106
+ for (const s of signals) {
107
+ for (const f of s.files)
108
+ allFiles.add(f);
109
+ toolNames.add(s.toolName);
110
+ totalLines += s.linesChanged;
111
+ totalDurationMs += s.durationMs;
112
+ }
113
+ const files = [...allFiles];
114
+ // Build effort signals
115
+ const effortSignals = {
116
+ fileCount: files.length,
117
+ linesChanged: totalLines,
118
+ toolCallCount: signals.length,
119
+ durationMs: totalDurationMs,
120
+ iterationCount: signals.length,
121
+ complexityIndicators: {
122
+ newFiles: toolNames.has('write_file'),
123
+ multiLanguage: hasMultipleLanguages(files),
124
+ tests: files.some(isTestFile),
125
+ configChanges: files.some(isConfigFile),
126
+ },
127
+ };
128
+ const effort = estimateEffort(effortSignals);
129
+ // Skip if below minimum effort threshold
130
+ if (EFFORT_ORDER.indexOf(effort) < EFFORT_ORDER.indexOf(this.minEffortToRecord)) {
131
+ return;
132
+ }
133
+ const episode = {
134
+ id: randomUUID(),
135
+ agentId: this.agentId,
136
+ terminalPrefix: this.terminalPrefix,
137
+ action: inferAction([...toolNames]),
138
+ summary: buildSummary([...toolNames], files, totalLines),
139
+ files,
140
+ linesChanged: totalLines > 0 ? totalLines : undefined,
141
+ timestamp: signals[0].timestamp,
142
+ sessionId: this.sessionId,
143
+ effort,
144
+ durationMs: startTime ? Date.now() - startTime : totalDurationMs,
145
+ toolCalls: signals.length,
146
+ };
147
+ void this.store.save(episode);
148
+ this.onBatchFlushed?.();
149
+ }
150
+ }
151
+ // =============================================================================
152
+ // Helpers (module-private)
153
+ // =============================================================================
154
+ function inferAction(toolNames) {
155
+ if (toolNames.includes('git_commit'))
156
+ return 'commit';
157
+ if (toolNames.includes('run_tests'))
158
+ return 'test';
159
+ if (toolNames.includes('run_lint'))
160
+ return 'lint';
161
+ if (toolNames.includes('run_build'))
162
+ return 'build';
163
+ if (toolNames.includes('write_file') && !toolNames.includes('edit'))
164
+ return 'create';
165
+ if (toolNames.includes('edit'))
166
+ return 'edit';
167
+ return 'modify';
168
+ }
169
+ function buildSummary(toolNames, files, totalLines) {
170
+ const action = inferAction(toolNames);
171
+ const fileStr = files.length === 1 ? files[0] : `${String(files.length)} files`;
172
+ const lineStr = totalLines > 0 ? ` (${String(totalLines)} lines)` : '';
173
+ return `${action}: ${fileStr}${lineStr}`;
174
+ }
175
+ function hasMultipleLanguages(files) {
176
+ const extensions = new Set();
177
+ for (const f of files) {
178
+ const ext = f.split('.').pop();
179
+ if (ext)
180
+ extensions.add(ext);
181
+ }
182
+ return extensions.size > 1;
183
+ }
184
+ function isTestFile(file) {
185
+ return /\.(test|spec)\.[a-z]+$/i.test(file) || file.includes('__tests__');
186
+ }
187
+ function isConfigFile(file) {
188
+ const base = file.split('/').pop() ?? '';
189
+ return (base.startsWith('.') ||
190
+ base === 'package.json' ||
191
+ base === 'tsconfig.json' ||
192
+ base.endsWith('.config.ts') ||
193
+ base.endsWith('.config.js') ||
194
+ base.endsWith('.config.mjs'));
195
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Significant Work Detection
3
+ *
4
+ * Determines which tool calls represent meaningful work (writes, edits,
5
+ * commits, runs) versus read-only exploration (reads, greps, globs).
6
+ *
7
+ * Uses hardcoded tool-name strings rather than a TOOL_NAMES constant so
8
+ * this module has zero dependency on any host's tool registry. Both
9
+ * agents-coding tools and platform tools use stable canonical names.
10
+ */
11
+ /**
12
+ * Check if a tool call represents significant work worth recording.
13
+ * Returns false for failed tool calls (fail-closed: don't record
14
+ * broken work).
15
+ */
16
+ export declare function isSignificantWork(toolName: string, _input: Record<string, unknown>, success: boolean): boolean;
17
+ /**
18
+ * Extract affected file paths from a tool call's input.
19
+ * Returns an empty array if no files can be extracted.
20
+ */
21
+ export declare function extractAffectedFiles(toolName: string, input: Record<string, unknown>): string[];
22
+ /**
23
+ * Extract lines changed from a tool result, best-effort.
24
+ * Returns 0 if the information is not available.
25
+ */
26
+ export declare function extractLinesChanged(result: Record<string, unknown>): number;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Significant Work Detection
3
+ *
4
+ * Determines which tool calls represent meaningful work (writes, edits,
5
+ * commits, runs) versus read-only exploration (reads, greps, globs).
6
+ *
7
+ * Uses hardcoded tool-name strings rather than a TOOL_NAMES constant so
8
+ * this module has zero dependency on any host's tool registry. Both
9
+ * agents-coding tools and platform tools use stable canonical names.
10
+ */
11
+ const SIGNIFICANT_TOOLS = new Set([
12
+ 'write_file',
13
+ 'edit',
14
+ 'git_commit',
15
+ 'git_stash',
16
+ 'run_tests',
17
+ 'run_lint',
18
+ 'run_build',
19
+ ]);
20
+ /**
21
+ * Check if a tool call represents significant work worth recording.
22
+ * Returns false for failed tool calls (fail-closed: don't record
23
+ * broken work).
24
+ */
25
+ export function isSignificantWork(toolName, _input, success) {
26
+ if (!success)
27
+ return false;
28
+ return SIGNIFICANT_TOOLS.has(toolName);
29
+ }
30
+ /**
31
+ * Extract affected file paths from a tool call's input.
32
+ * Returns an empty array if no files can be extracted.
33
+ */
34
+ export function extractAffectedFiles(toolName, input) {
35
+ if (toolName === 'write_file' && typeof input.path === 'string') {
36
+ return [input.path];
37
+ }
38
+ if (toolName === 'edit' && typeof input.filePath === 'string') {
39
+ return [input.filePath];
40
+ }
41
+ return [];
42
+ }
43
+ /**
44
+ * Extract lines changed from a tool result, best-effort.
45
+ * Returns 0 if the information is not available.
46
+ */
47
+ export function extractLinesChanged(result) {
48
+ // Some tools report linesWritten directly
49
+ if (typeof result.linesWritten === 'number') {
50
+ return result.linesWritten;
51
+ }
52
+ // Edit results may have linesAdded + linesRemoved
53
+ const added = typeof result.linesAdded === 'number' ? result.linesAdded : 0;
54
+ const removed = typeof result.linesRemoved === 'number' ? result.linesRemoved : 0;
55
+ if (added > 0 || removed > 0) {
56
+ return added + removed;
57
+ }
58
+ return 0;
59
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * File-Based Episode Store
3
+ *
4
+ * Persists work episodes to a JSON file per project. Uses an in-memory
5
+ * cache — all reads are synchronous, writes are async (fire-and-forget).
6
+ */
7
+ import type { WorkEpisode, ProjectWorkSummary, Effort, EpisodeStore } from '@compilr-dev/agents';
8
+ export interface FileEpisodeStoreOptions {
9
+ /** Path to the episodes JSON file */
10
+ filePath: string;
11
+ /** Maximum episodes to keep (oldest pruned on flush). Default: 1000 */
12
+ maxEpisodes?: number;
13
+ /** Maximum age in milliseconds for cleanup. Default: 30 days */
14
+ maxAgeMs?: number;
15
+ }
16
+ export declare class FileEpisodeStore implements EpisodeStore {
17
+ private readonly filePath;
18
+ private readonly maxEpisodes;
19
+ private readonly maxAgeMs;
20
+ private _episodes;
21
+ private flushPromise;
22
+ constructor(options: FileEpisodeStoreOptions);
23
+ /** Lazy-load and return the in-memory episode cache */
24
+ private get episodes();
25
+ save(episode: WorkEpisode): void;
26
+ saveBatch(episodes: WorkEpisode[]): void;
27
+ getAll(): WorkEpisode[];
28
+ getByFiles(files: string[]): WorkEpisode[];
29
+ getByAgent(agentId: string): WorkEpisode[];
30
+ getBySession(sessionId: string): WorkEpisode[];
31
+ getByTimeRange(start: string, end: string): WorkEpisode[];
32
+ getRecent(count: number): WorkEpisode[];
33
+ getWorkSummary(): ProjectWorkSummary;
34
+ getTotalEffort(episodes?: WorkEpisode[]): Effort;
35
+ cleanup(maxAgeMs?: number): number;
36
+ private loadFromDisk;
37
+ private flush;
38
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * File-Based Episode Store
3
+ *
4
+ * Persists work episodes to a JSON file per project. Uses an in-memory
5
+ * cache — all reads are synchronous, writes are async (fire-and-forget).
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { EFFORT_ORDER } from '@compilr-dev/agents';
10
+ // =============================================================================
11
+ // Constants
12
+ // =============================================================================
13
+ const DEFAULT_MAX_EPISODES = 1000;
14
+ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
15
+ // =============================================================================
16
+ // Implementation
17
+ // =============================================================================
18
+ export class FileEpisodeStore {
19
+ filePath;
20
+ maxEpisodes;
21
+ maxAgeMs;
22
+ _episodes = null; // lazy-loaded
23
+ flushPromise = null;
24
+ constructor(options) {
25
+ this.filePath = options.filePath;
26
+ this.maxEpisodes = options.maxEpisodes ?? DEFAULT_MAX_EPISODES;
27
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
28
+ }
29
+ /** Lazy-load and return the in-memory episode cache */
30
+ get episodes() {
31
+ if (this._episodes === null) {
32
+ this._episodes = this.loadFromDisk();
33
+ }
34
+ return this._episodes;
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Write methods
38
+ // ---------------------------------------------------------------------------
39
+ save(episode) {
40
+ this.episodes.push(episode);
41
+ void this.flush();
42
+ }
43
+ saveBatch(episodes) {
44
+ if (episodes.length === 0)
45
+ return;
46
+ this.episodes.push(...episodes);
47
+ void this.flush();
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Read methods (synchronous — read from cache)
51
+ // ---------------------------------------------------------------------------
52
+ getAll() {
53
+ return [...this.episodes];
54
+ }
55
+ getByFiles(files) {
56
+ const fileSet = new Set(files);
57
+ return this.episodes.filter((ep) => ep.files.some((f) => fileSet.has(f)));
58
+ }
59
+ getByAgent(agentId) {
60
+ return this.episodes.filter((ep) => ep.agentId === agentId);
61
+ }
62
+ getBySession(sessionId) {
63
+ return this.episodes.filter((ep) => ep.sessionId === sessionId);
64
+ }
65
+ getByTimeRange(start, end) {
66
+ return this.episodes.filter((ep) => ep.timestamp >= start && ep.timestamp <= end);
67
+ }
68
+ getRecent(count) {
69
+ return this.episodes.slice(-count);
70
+ }
71
+ getWorkSummary() {
72
+ const eps = this.episodes;
73
+ // Agent breakdown
74
+ const agentMap = new Map();
75
+ for (const ep of eps) {
76
+ const entry = agentMap.get(ep.agentId) ?? {
77
+ count: 0,
78
+ maxEffort: 'trivial',
79
+ timeMs: 0,
80
+ };
81
+ entry.count++;
82
+ if (EFFORT_ORDER.indexOf(ep.effort) > EFFORT_ORDER.indexOf(entry.maxEffort)) {
83
+ entry.maxEffort = ep.effort;
84
+ }
85
+ entry.timeMs += ep.durationMs ?? 0;
86
+ agentMap.set(ep.agentId, entry);
87
+ }
88
+ // Top files
89
+ const fileCount = new Map();
90
+ for (const ep of eps) {
91
+ for (const f of ep.files) {
92
+ fileCount.set(f, (fileCount.get(f) ?? 0) + 1);
93
+ }
94
+ }
95
+ const topFiles = [...fileCount.entries()]
96
+ .sort((a, b) => b[1] - a[1])
97
+ .slice(0, 10)
98
+ .map(([p, touchCount]) => ({ path: p, touchCount }));
99
+ // Uncommitted work: episodes after last git_commit episode
100
+ let lastCommitIdx = -1;
101
+ for (let i = eps.length - 1; i >= 0; i--) {
102
+ if (eps[i].action === 'commit') {
103
+ lastCommitIdx = i;
104
+ break;
105
+ }
106
+ }
107
+ const uncommittedWork = lastCommitIdx >= 0 ? eps.slice(lastCommitIdx + 1) : [...eps];
108
+ return {
109
+ episodeCount: eps.length,
110
+ totalEffort: this.getTotalEffort(eps),
111
+ timeSpentMs: eps.reduce((sum, ep) => sum + (ep.durationMs ?? 0), 0),
112
+ agentBreakdown: [...agentMap.entries()].map(([agentId, entry]) => ({
113
+ agentId,
114
+ episodeCount: entry.count,
115
+ maxEffort: entry.maxEffort,
116
+ timeSpentMs: entry.timeMs,
117
+ })),
118
+ topFiles,
119
+ uncommittedWork,
120
+ };
121
+ }
122
+ getTotalEffort(episodes) {
123
+ const eps = episodes ?? this.episodes;
124
+ if (eps.length === 0)
125
+ return 'trivial';
126
+ return maxEffort(eps);
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Cleanup
130
+ // ---------------------------------------------------------------------------
131
+ cleanup(maxAgeMs) {
132
+ const cutoff = new Date(Date.now() - (maxAgeMs ?? this.maxAgeMs)).toISOString();
133
+ const before = this.episodes.length;
134
+ this._episodes = this.episodes.filter((ep) => ep.timestamp > cutoff);
135
+ const removed = before - this._episodes.length;
136
+ if (removed > 0) {
137
+ void this.flush();
138
+ }
139
+ return removed;
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Internal
143
+ // ---------------------------------------------------------------------------
144
+ loadFromDisk() {
145
+ try {
146
+ if (!fs.existsSync(this.filePath))
147
+ return [];
148
+ const raw = fs.readFileSync(this.filePath, 'utf-8');
149
+ const data = JSON.parse(raw);
150
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime data from disk, version may differ
151
+ if (data.version === 1 && Array.isArray(data.episodes)) {
152
+ return data.episodes;
153
+ }
154
+ return [];
155
+ }
156
+ catch {
157
+ // Corrupted file — start fresh
158
+ return [];
159
+ }
160
+ }
161
+ async flush() {
162
+ // Coalesce concurrent flushes
163
+ if (this.flushPromise)
164
+ return this.flushPromise;
165
+ this.flushPromise = (async () => {
166
+ try {
167
+ const dir = path.dirname(this.filePath);
168
+ await fs.promises.mkdir(dir, { recursive: true });
169
+ // Prune to max episodes (keep most recent)
170
+ const eps = this.episodes;
171
+ const prunedEpisodes = eps.length > this.maxEpisodes ? eps.slice(-this.maxEpisodes) : eps;
172
+ if (prunedEpisodes !== eps) {
173
+ this._episodes = prunedEpisodes;
174
+ }
175
+ const data = {
176
+ version: 1,
177
+ episodes: prunedEpisodes,
178
+ lastCleanup: new Date().toISOString(),
179
+ };
180
+ await fs.promises.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
181
+ }
182
+ catch {
183
+ // Silently fail — episodes are best-effort, never crash the host
184
+ }
185
+ finally {
186
+ this.flushPromise = null;
187
+ }
188
+ })();
189
+ return this.flushPromise;
190
+ }
191
+ }
192
+ // =============================================================================
193
+ // Helpers (module-private)
194
+ // =============================================================================
195
+ function maxEffort(episodes) {
196
+ let max = 'trivial';
197
+ for (const ep of episodes) {
198
+ if (EFFORT_ORDER.indexOf(ep.effort) > EFFORT_ORDER.indexOf(max)) {
199
+ max = ep.effort;
200
+ }
201
+ }
202
+ return max;
203
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Episode Module Internal Types
3
+ *
4
+ * Re-exports core types from `@compilr-dev/agents` and adds the
5
+ * internal types needed by the file-based store and the recorder.
6
+ */
7
+ export type { Effort, WorkEpisode, EffortSignals, EffortWeights, EffortSummary, ProjectWorkSummary, WorkAtRisk, EpisodeStore, } from '@compilr-dev/agents';
8
+ /**
9
+ * Pending tool signal — accumulated during a batch window before
10
+ * being rolled into a WorkEpisode.
11
+ */
12
+ export interface PendingToolSignal {
13
+ /** Tool name that was invoked */
14
+ toolName: string;
15
+ /** Files affected by this tool call */
16
+ files: string[];
17
+ /** Lines changed, if known */
18
+ linesChanged: number;
19
+ /** Duration of the tool call in milliseconds */
20
+ durationMs: number;
21
+ /** Whether the tool call succeeded */
22
+ success: boolean;
23
+ /** ISO timestamp of the tool call */
24
+ timestamp: string;
25
+ }
26
+ /**
27
+ * On-disk format for the episodes JSON file.
28
+ */
29
+ export interface EpisodeFile {
30
+ /** File format version */
31
+ version: 1;
32
+ /** All stored episodes */
33
+ episodes: import('@compilr-dev/agents').WorkEpisode[];
34
+ /** ISO timestamp of last cleanup */
35
+ lastCleanup: string;
36
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Episode Module Internal Types
3
+ *
4
+ * Re-exports core types from `@compilr-dev/agents` and adds the
5
+ * internal types needed by the file-based store and the recorder.
6
+ */
7
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Work At Risk Query
3
+ *
4
+ * Pure function to query episodes by affected files and build a
5
+ * WorkAtRisk summary. Used by guardrails and rehearsal to warn about
6
+ * destructive operations.
7
+ */
8
+ import type { EpisodeStore, WorkAtRisk } from '@compilr-dev/agents';
9
+ /**
10
+ * Query episodes that touch any of the given files and build a
11
+ * WorkAtRisk summary. Returns null if no episodes are at risk.
12
+ */
13
+ export declare function queryWorkAtRisk(store: EpisodeStore, files: string[]): WorkAtRisk | null;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Work At Risk Query
3
+ *
4
+ * Pure function to query episodes by affected files and build a
5
+ * WorkAtRisk summary. Used by guardrails and rehearsal to warn about
6
+ * destructive operations.
7
+ */
8
+ import { EFFORT_ORDER } from '@compilr-dev/agents';
9
+ /**
10
+ * Query episodes that touch any of the given files and build a
11
+ * WorkAtRisk summary. Returns null if no episodes are at risk.
12
+ */
13
+ export function queryWorkAtRisk(store, files) {
14
+ const episodes = store.getByFiles(files);
15
+ if (episodes.length === 0)
16
+ return null;
17
+ // Max effort
18
+ let totalEffort = 'trivial';
19
+ for (const ep of episodes) {
20
+ if (EFFORT_ORDER.indexOf(ep.effort) > EFFORT_ORDER.indexOf(totalEffort)) {
21
+ totalEffort = ep.effort;
22
+ }
23
+ }
24
+ // Agent attribution: "agentId (terminalPrefix)"
25
+ const seen = new Set();
26
+ const agentAttribution = [];
27
+ for (const ep of episodes) {
28
+ const key = `${ep.agentId} (${ep.terminalPrefix})`;
29
+ if (!seen.has(key)) {
30
+ seen.add(key);
31
+ agentAttribution.push(key);
32
+ }
33
+ }
34
+ // Warning message
35
+ const fileCount = new Set(episodes.flatMap((ep) => ep.files)).size;
36
+ const warningMessage = `⚠️ ${String(episodes.length)} episode(s) of work (effort: ${totalEffort}) ` +
37
+ `on ${String(fileCount)} file(s) by ${agentAttribution.join(', ')} would be affected.`;
38
+ return { episodes, totalEffort, agentAttribution, warningMessage };
39
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Work Summary Anchor
3
+ *
4
+ * Manages an auto-generated anchor that summarizes work done in the
5
+ * current session. Updated automatically after each episode batch is
6
+ * flushed (wire via `EpisodeRecorderConfig.onBatchFlushed`).
7
+ */
8
+ import type { EpisodeStore, ProjectWorkSummary, AnchorManager } from '@compilr-dev/agents';
9
+ export interface WorkSummaryAnchorConfig {
10
+ store: EpisodeStore;
11
+ projectId: string;
12
+ getAnchorManager: (projectId: string) => AnchorManager;
13
+ }
14
+ /**
15
+ * Build the anchor content string from the store's work summary.
16
+ * Returns null when there are no episodes to summarize.
17
+ */
18
+ export declare function buildWorkSummaryContent(summary: ProjectWorkSummary): string | null;
19
+ /**
20
+ * Update the work summary anchor for a project.
21
+ * Removes existing anchor (if any) and adds updated one.
22
+ */
23
+ export declare function updateWorkSummaryAnchor(config: WorkSummaryAnchorConfig): void;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Work Summary Anchor
3
+ *
4
+ * Manages an auto-generated anchor that summarizes work done in the
5
+ * current session. Updated automatically after each episode batch is
6
+ * flushed (wire via `EpisodeRecorderConfig.onBatchFlushed`).
7
+ */
8
+ const ANCHOR_TAG = 'episode-work-summary';
9
+ /**
10
+ * Build the anchor content string from the store's work summary.
11
+ * Returns null when there are no episodes to summarize.
12
+ */
13
+ export function buildWorkSummaryContent(summary) {
14
+ if (summary.episodeCount === 0)
15
+ return null;
16
+ const lines = ['[Session Work Summary]'];
17
+ lines.push(`Episodes: ${String(summary.episodeCount)}, Effort: ${summary.totalEffort}, Time: ${formatMs(summary.timeSpentMs)}`);
18
+ if (summary.agentBreakdown.length > 0) {
19
+ const agents = summary.agentBreakdown
20
+ .map((a) => `${a.agentId}(${String(a.episodeCount)})`)
21
+ .join(', ');
22
+ lines.push(`Agents: ${agents}`);
23
+ }
24
+ if (summary.topFiles.length > 0) {
25
+ const files = summary.topFiles
26
+ .slice(0, 5)
27
+ .map((f) => f.path.split('/').pop() ?? f.path)
28
+ .join(', ');
29
+ lines.push(`Top files: ${files}`);
30
+ }
31
+ if (summary.uncommittedWork.length > 0) {
32
+ lines.push(`Uncommitted: ${String(summary.uncommittedWork.length)} episode(s)`);
33
+ }
34
+ return lines.join('\n');
35
+ }
36
+ /**
37
+ * Update the work summary anchor for a project.
38
+ * Removes existing anchor (if any) and adds updated one.
39
+ */
40
+ export function updateWorkSummaryAnchor(config) {
41
+ const { store, projectId, getAnchorManager: getManager } = config;
42
+ const manager = getManager(projectId);
43
+ const summary = store.getWorkSummary();
44
+ const content = buildWorkSummaryContent(summary);
45
+ // Remove existing work summary anchor
46
+ const existing = manager.getAll().find((a) => a.tags?.includes(ANCHOR_TAG));
47
+ if (existing)
48
+ manager.remove(existing.id);
49
+ // Add new anchor if there's content
50
+ if (content) {
51
+ manager.add({
52
+ content,
53
+ priority: 'info',
54
+ scope: 'persistent',
55
+ tags: [ANCHOR_TAG],
56
+ projectId,
57
+ });
58
+ }
59
+ }
60
+ /**
61
+ * Format milliseconds to a human-readable string (e.g., "2h 15m", "45s").
62
+ */
63
+ function formatMs(ms) {
64
+ if (ms < 1000)
65
+ return `${String(ms)}ms`;
66
+ const totalSeconds = Math.floor(ms / 1000);
67
+ if (totalSeconds < 60)
68
+ return `${String(totalSeconds)}s`;
69
+ const minutes = Math.floor(totalSeconds / 60);
70
+ if (minutes < 60)
71
+ return `${String(minutes)}m`;
72
+ const hours = Math.floor(minutes / 60);
73
+ const remainingMinutes = minutes % 60;
74
+ return remainingMinutes > 0
75
+ ? `${String(hours)}h ${String(remainingMinutes)}m`
76
+ : `${String(hours)}h`;
77
+ }
@@ -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
@@ -82,6 +82,10 @@ export type { Tool, HooksConfig, AgentEvent, Message, LLMProvider, AnchorInput,
82
82
  export { DEFAULT_PERMISSION_RULES, findMatchingRule, permissionModeLabel, permissionLevelLabel, } from './permissions.js';
83
83
  export type { PermissionRule, PermissionMode, PermissionLevel } from './permissions.js';
84
84
  export { DEFAULT_DELEGATION_CONFIG } from './delegation.js';
85
+ export { FileEpisodeStore, EpisodeRecorder, isSignificantWork, extractAffectedFiles, extractLinesChanged, queryWorkAtRisk, buildWorkSummaryContent, updateWorkSummaryAnchor, } from './episodes/index.js';
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';
85
89
  export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, } from './mcp-config.js';
86
90
  export type { MCPServerEntry, MCPConfigFile, ResolvedMCPServer } from './mcp-config.js';
87
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
@@ -202,6 +202,14 @@ export { DEFAULT_PERMISSION_RULES, findMatchingRule, permissionModeLabel, permis
202
202
  // =============================================================================
203
203
  export { DEFAULT_DELEGATION_CONFIG } from './delegation.js';
204
204
  // =============================================================================
205
+ // Episodes — Work History Tracking
206
+ // =============================================================================
207
+ export { FileEpisodeStore, EpisodeRecorder, isSignificantWork, extractAffectedFiles, extractLinesChanged, queryWorkAtRisk, buildWorkSummaryContent, updateWorkSummaryAnchor, } from './episodes/index.js';
208
+ // =============================================================================
209
+ // Flow Runner — `build_interactive_flow` State Machine Helpers
210
+ // =============================================================================
211
+ export { resolveNext, evaluateBranchCondition, walkPastBranches, lookupLabel, applyBacktrack, } from './flow-runner/index.js';
212
+ // =============================================================================
205
213
  // Shared MCP Configuration
206
214
  // =============================================================================
207
215
  export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, } from './mcp-config.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.10.31",
3
+ "version": "0.10.33",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",