diff --git a/.changeset/deterministic-workflow-macros.md b/.changeset/deterministic-workflow-macros.md
new file mode 100644
index 000000000..9c5307a29
--- /dev/null
+++ b/.changeset/deterministic-workflow-macros.md
@@ -0,0 +1,5 @@
+---
+'@nanocollective/nanocoder': minor
+---
+
+feat: add read-only sequence tracker and /macros command to discover recurring workflows (#1585)
diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts
index 1a339eae6..1468216cc 100644
--- a/source/app/utils/app-util.ts
+++ b/source/app/utils/app-util.ts
@@ -15,6 +15,7 @@ import {
DELAY_COMMAND_COMPLETE_MS,
MAX_SESSION_NAME_LENGTH,
} from '@/constants';
+import {getSequenceTracker} from '@/macros/sequence-tracker';
import {sharedProposalStore} from '@/memory/proposal-store';
import {CheckpointManager} from '@/services/checkpoint-manager';
import {clearPendingHookContext} from '@/services/lifecycle-hooks';
@@ -341,6 +342,11 @@ async function handleSpecialCommand(
case SPECIAL_COMMANDS.CLEAR:
await onClearMessages();
sharedProposalStore.clear();
+ try {
+ getSequenceTracker().clear();
+ } catch {
+ // Non-fatal
+ }
options.onClearCounterIncrement?.();
setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS);
return true;
diff --git a/source/commands/lazy-registry.ts b/source/commands/lazy-registry.ts
index a0d5de83a..2797dca02 100644
--- a/source/commands/lazy-registry.ts
+++ b/source/commands/lazy-registry.ts
@@ -223,6 +223,12 @@ export const lazyCommands: LazyCommand[] = [
'Tune model settings (parameters, tool profiles, prompt, compaction)',
load: () => import('@/commands/tune').then(m => m.tuneCommand),
},
+ {
+ name: 'macros',
+ description:
+ 'Inspect repeated read-only tool sequences (/macros, /macros clear)',
+ load: () => import('@/commands/macros').then(m => m.macrosCommand),
+ },
{
name: 'schedule',
description:
diff --git a/source/commands/macros.spec.tsx b/source/commands/macros.spec.tsx
new file mode 100644
index 000000000..cf64c7431
--- /dev/null
+++ b/source/commands/macros.spec.tsx
@@ -0,0 +1,97 @@
+import test from 'ava';
+import React from 'react';
+import {getSequenceTracker, resetSequenceTracker} from '@/macros/sequence-tracker';
+import {macrosCommand} from './macros';
+
+test.beforeEach(() => {
+ resetSequenceTracker();
+});
+
+test.serial('macrosCommand renders empty view when no patterns recorded', async t => {
+ const result = await macrosCommand.handler([], [], {
+ provider: 'ollama',
+ model: 'test-model',
+ tokens: 0,
+ getMessageTokens: () => 0,
+ });
+
+ t.truthy(result);
+ t.true(React.isValidElement(result));
+});
+
+test.serial('macrosCommand renders patterns when sequences are tracked', async t => {
+ const tracker = getSequenceTracker();
+ // Run 1
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ // Run 2 (repeat)
+ tracker.breakChain();
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ const result = await macrosCommand.handler([], [], {
+ provider: 'ollama',
+ model: 'test-model',
+ tokens: 0,
+ getMessageTokens: () => 0,
+ });
+
+ t.truthy(result);
+ t.true(React.isValidElement(result));
+ t.is(tracker.getCandidates().length, 1);
+});
+
+test.serial('macrosCommand handles clear subcommand', async t => {
+ const tracker = getSequenceTracker();
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ t.is(tracker.getPatterns().length, 1);
+
+ const result = await macrosCommand.handler(['clear'], [], {
+ provider: 'ollama',
+ model: 'test-model',
+ tokens: 0,
+ getMessageTokens: () => 0,
+ });
+
+ t.truthy(result);
+ t.is(tracker.getPatterns().length, 0);
+});
diff --git a/source/commands/macros.tsx b/source/commands/macros.tsx
new file mode 100644
index 000000000..0dcdbb414
--- /dev/null
+++ b/source/commands/macros.tsx
@@ -0,0 +1,96 @@
+import {Box, Text} from 'ink';
+import React from 'react';
+import {TitledBoxWithPreferences} from '@/components/ui/titled-box';
+import {useTerminalWidth} from '@/hooks/useTerminalWidth';
+import {useTheme} from '@/hooks/useTheme';
+import {getSequenceTracker} from '@/macros/sequence-tracker';
+import {generateKey} from '@/session/key-generator';
+import type {Command} from '@/types/index';
+import type {WorkflowPattern} from '@/types/macros';
+import {successMsg} from '@/utils/message-factory';
+
+export interface MacroWorkflowViewProps {
+ patterns: WorkflowPattern[];
+}
+
+export function MacroWorkflowView({patterns}: MacroWorkflowViewProps) {
+ const boxWidth = useTerminalWidth();
+ const {colors} = useTheme();
+
+ return (
+
+
+
+ Tracked Read-Only Workflow Patterns:
+
+
+
+ {patterns.length === 0 ? (
+
+
+ No repeated read-only tool sequences detected yet in this session.
+
+
+ ) : (
+ patterns.map(p => (
+
+
+
+ [{p.id}]
+
+ {p.signature}
+
+ {' '}
+ — {p.occurrences} {p.occurrences === 1 ? 'time' : 'times'}
+
+
+
+ ))
+ )}
+
+
+
+ Tip: Discovered read-only patterns represent recurring candidate
+ workflows in this session.
+
+
+
+ );
+}
+
+export const macrosCommand: Command = {
+ name: 'macros',
+ description:
+ 'Inspect repeated read-only tool sequences (/macros, /macros clear)',
+ handler: async (args, _messages, _metadata) => {
+ const tracker = getSequenceTracker();
+ const sub = (args[0] ?? '').toLowerCase().trim();
+
+ if (sub === 'clear') {
+ tracker.clear();
+ return successMsg(
+ 'Cleared sequence tracker history and discovered patterns.',
+ 'macros',
+ );
+ }
+
+ const patterns = tracker.getCandidates();
+ return React.createElement(MacroWorkflowView, {
+ key: generateKey('macros'),
+ patterns,
+ });
+ },
+};
diff --git a/source/hooks/chat-handler/conversation/tool-executor.spec.ts b/source/hooks/chat-handler/conversation/tool-executor.spec.ts
index 797012c28..e203851b6 100644
--- a/source/hooks/chat-handler/conversation/tool-executor.spec.ts
+++ b/source/hooks/chat-handler/conversation/tool-executor.spec.ts
@@ -1,6 +1,7 @@
import test from 'ava';
import {displayExecutedTool, executeToolsDirectly} from './tool-executor.js';
import type {ToolCall, ToolResult} from '@/types/core';
+import {getSequenceTracker} from '@/macros/sequence-tracker';
// ============================================================================
// Test Helpers
@@ -1209,3 +1210,111 @@ test.serial(
setToolRegistryGetter(createMockToolRegistry);
},
);
+
+test.serial(
+ 'executeToolsDirectly - records read-only tool executions into SequenceTracker',
+ async t => {
+ const tracker = getSequenceTracker();
+ tracker.clear();
+
+ const toolCalls: ToolCall[] = [
+ {
+ id: 'call_ro_1',
+ function: {
+ name: 'test_tool',
+ arguments: JSON.stringify({path: 'source/test.ts'}),
+ },
+ },
+ ];
+
+ const mockToolManager = createMockToolManager({
+ readOnlyTools: ['test_tool'],
+ });
+
+ await executeToolsDirectly(
+ toolCalls,
+ mockToolManager as any,
+ createMockConversationStateManager() as any,
+ () => {},
+ );
+
+ const history = tracker.getHistory();
+ t.true(history.length >= 1);
+ const last = history[history.length - 1];
+ t.is(last.toolName, 'test_tool');
+ t.is((last.inputArgs as any).path, 'source/test.ts');
+ t.true(last.readOnly);
+ t.true(last.success);
+ },
+);
+
+test.serial(
+ 'executeToolsDirectly - breaks chain on non-read-only tool call',
+ async t => {
+ const tracker = getSequenceTracker();
+ tracker.clear();
+
+ // Execute read-only tool first
+ await executeToolsDirectly(
+ [
+ {
+ id: 'call_ro',
+ function: {name: 'test_tool', arguments: '{"path": "a"}'},
+ },
+ ],
+ createMockToolManager({readOnlyTools: ['test_tool']}) as any,
+ createMockConversationStateManager() as any,
+ () => {},
+ );
+
+ t.is(tracker.getCurrentChain().length, 1);
+
+ // Execute non-read-only tool
+ await executeToolsDirectly(
+ [
+ {
+ id: 'call_mut',
+ function: {name: 'mutating_tool', arguments: '{"content": "b"}'},
+ },
+ ],
+ createMockToolManager({readOnlyTools: []}) as any,
+ createMockConversationStateManager() as any,
+ () => {},
+ );
+
+ // Chain must be broken
+ t.is(tracker.getCurrentChain().length, 0);
+ },
+);
+
+test.serial(
+ 'executeToolsDirectly - tags concurrent read-only tools with parallelBatch',
+ async t => {
+ const tracker = getSequenceTracker();
+ tracker.clear();
+
+ // Two read-only tools executed together
+ await executeToolsDirectly(
+ [
+ {
+ id: 'call_ro_1',
+ function: {name: 'tool1', arguments: '{}'},
+ },
+ {
+ id: 'call_ro_2',
+ function: {name: 'tool2', arguments: '{}'},
+ },
+ ],
+ createMockToolManager({readOnlyTools: ['tool1', 'tool2']}) as any,
+ createMockConversationStateManager() as any,
+ () => {},
+ );
+
+ const chain = tracker.getCurrentChain();
+ t.is(chain.length, 2);
+ t.true(chain[0].parallelBatch);
+ t.true(chain[1].parallelBatch);
+ },
+);
+
+
diff --git a/source/hooks/chat-handler/conversation/tool-executor.tsx b/source/hooks/chat-handler/conversation/tool-executor.tsx
index cecc8003a..80d8580fa 100644
--- a/source/hooks/chat-handler/conversation/tool-executor.tsx
+++ b/source/hooks/chat-handler/conversation/tool-executor.tsx
@@ -4,6 +4,7 @@ import AgentProgress, {MultiAgentProgress} from '@/components/agent-progress';
import BashProgress from '@/components/bash-progress';
import {ErrorMessage} from '@/components/message-box';
import {getShowAgentBashOutput} from '@/config/preferences';
+import {getSequenceTracker} from '@/macros/sequence-tracker';
import type {BashExecutionState} from '@/services/bash-executor';
import {
clearAllSubagentProgress,
@@ -87,6 +88,7 @@ export interface ToolDisplayOptions {
onCompactToolCount?: (toolName: string) => void;
onLiveTaskUpdate?: (tasks?: Task[]) => void;
nonInteractiveMode?: boolean;
+ isParallelBatch?: boolean;
}
/**
@@ -140,6 +142,34 @@ export const displayExecutedTool = async (
toolCall,
result.content,
);
+
+ try {
+ const tracker = getSequenceTracker();
+ const isReadOnly = toolManager?.isReadOnly(result.name) ?? false;
+ const isError = isToolResultError(result);
+
+ if (!isReadOnly || isError) {
+ tracker.breakChain();
+ } else {
+ const parsedArgs = parseToolArguments(toolCall.function.arguments);
+ const inputArgs =
+ typeof parsedArgs === 'object' && parsedArgs !== null
+ ? (parsedArgs as Record)
+ : {};
+
+ tracker.recordExecution({
+ toolName: result.name,
+ inputArgs,
+ success: true,
+ readOnly: true,
+ parallelBatch: options?.isParallelBatch,
+ timestamp: Date.now(),
+ });
+ }
+ } catch {
+ // Non-fatal tracking fallback
+ }
+
const expandId = recordExpandableToolResult(toolCall, result, bashState);
if (
@@ -554,6 +584,11 @@ export const executeToolsDirectly = async (
result.content,
);
}
+ try {
+ getSequenceTracker().breakChain();
+ } catch {
+ // Non-fatal tracking fallback
+ }
continue;
}
@@ -578,6 +613,11 @@ export const executeToolsDirectly = async (
}
}
+ const isParallelBatch = type === 'readOnly' && group.length > 1;
+ const displayOpts = isParallelBatch
+ ? {...options, isParallelBatch: true}
+ : options;
+
// Display results in order
for (const execution of executions) {
directResults.push(execution.result);
@@ -586,7 +626,7 @@ export const executeToolsDirectly = async (
toolManager,
addToChatQueue,
conversationStateManager,
- options,
+ displayOpts,
);
}
}
diff --git a/source/macros/sequence-tracker.spec.ts b/source/macros/sequence-tracker.spec.ts
new file mode 100644
index 000000000..523b81917
--- /dev/null
+++ b/source/macros/sequence-tracker.spec.ts
@@ -0,0 +1,263 @@
+import test from 'ava';
+import {SequenceTracker} from './sequence-tracker.js';
+
+test('SequenceTracker detects repeated multi-step read-only tool sequences', t => {
+ const tracker = new SequenceTracker({minOccurrencesForCandidate: 2});
+
+ // Sequence 1: find_files -> read_file
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {pattern: '*.ts'},
+ success: true,
+ readOnly: true,
+ durationMs: 100,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: 'source/app.tsx'},
+ success: true,
+ readOnly: true,
+ durationMs: 50,
+ timestamp: Date.now(),
+ });
+
+ // After 1 run, pattern exists with 1 occurrence, not yet a candidate
+ const patterns1 = tracker.getPatterns(1);
+ t.is(patterns1.length, 1);
+ t.is(patterns1[0].signature, 'find_files -> read_file');
+ t.is(tracker.getCandidates().length, 0);
+
+ // Sequence 2: find_files -> read_file again
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {pattern: '*.spec.ts'},
+ success: true,
+ readOnly: true,
+ durationMs: 120,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: 'source/app.spec.tsx'},
+ success: true,
+ readOnly: true,
+ durationMs: 40,
+ timestamp: Date.now(),
+ });
+
+ // Now it has 2 occurrences and is a candidate macro
+ const candidates = tracker.getCandidates();
+ t.is(candidates.length, 1);
+ t.is(candidates[0].signature, 'find_files -> read_file');
+ t.is(candidates[0].occurrences, 2);
+ t.truthy(candidates[0].exemplarRecords);
+ t.is(candidates[0].exemplarRecords?.length, 2);
+ t.is(candidates[0].exemplarRecords?.[0].inputArgs.pattern, '*.spec.ts');
+ t.is(candidates[0].exemplarRecords?.[1].inputArgs.path, 'source/app.spec.tsx');
+});
+
+test('SequenceTracker preserves parallelBatch tag on exemplar records', t => {
+ const tracker = new SequenceTracker({minOccurrencesForCandidate: 1});
+
+ tracker.recordExecution({
+ toolName: 'search_file_contents',
+ inputArgs: {query: 'foo'},
+ success: true,
+ readOnly: true,
+ parallelBatch: true,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: 'foo.ts'},
+ success: true,
+ readOnly: true,
+ parallelBatch: true,
+ timestamp: Date.now(),
+ });
+
+ const patterns = tracker.getPatterns(1);
+ t.is(patterns.length, 1);
+ t.true(patterns[0].exemplarRecords?.[0].parallelBatch);
+ t.true(patterns[0].exemplarRecords?.[1].parallelBatch);
+});
+
+test('SequenceTracker ignores non-read-only tool executions and breaks chains', t => {
+ const tracker = new SequenceTracker({minOccurrencesForCandidate: 1});
+
+ // find_files (read-only) -> write_file (non-read-only) -> read_file (read-only)
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {pattern: '*.ts'},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'write_file',
+ inputArgs: {path: 'test.txt', content: 'hello'},
+ success: true,
+ readOnly: false,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: 'test.txt'},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ // No contiguous 2+ read-only pattern should be extracted
+ t.is(tracker.getPatterns().length, 0);
+ t.is(tracker.getCurrentChain().length, 1);
+});
+
+test('SequenceTracker breakChain explicitly resets the active chain', t => {
+ const tracker = new SequenceTracker({minOccurrencesForCandidate: 1});
+
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ t.is(tracker.getCurrentChain().length, 1);
+
+ // Explicitly break chain (e.g., agent batch or mutation occurred)
+ tracker.breakChain();
+
+ t.is(tracker.getCurrentChain().length, 0);
+
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ // Separated by breakChain, so no 2-step sequence formed
+ t.is(tracker.getPatterns().length, 0);
+ t.is(tracker.getCurrentChain().length, 1);
+});
+
+test('SequenceTracker ignores failed tool executions and breaks chains', t => {
+ const tracker = new SequenceTracker();
+
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {pattern: 'fail'},
+ success: false,
+ readOnly: true,
+ durationMs: 10,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: 'app.ts'},
+ success: true,
+ readOnly: true,
+ durationMs: 10,
+ timestamp: Date.now(),
+ });
+
+ // No successful 2-step sequence formed
+ t.is(tracker.getPatterns().length, 0);
+});
+
+test('SequenceTracker evicts history beyond maxHistory', t => {
+ const tracker = new SequenceTracker({maxHistory: 3});
+
+ for (let i = 0; i < 5; i++) {
+ tracker.recordExecution({
+ toolName: `read_file_${i}`,
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ }
+
+ t.is(tracker.getHistory().length, 3);
+ t.is(tracker.getHistory()[0].toolName, 'read_file_2');
+ t.is(tracker.getHistory()[2].toolName, 'read_file_4');
+});
+
+test('SequenceTracker clear resets patterns, history, and active chain', t => {
+ const tracker = new SequenceTracker();
+ tracker.recordExecution({
+ toolName: 'find_files',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ t.is(tracker.getPatterns().length, 1);
+ t.is(tracker.getHistory().length, 2);
+ t.is(tracker.getCurrentChain().length, 2);
+
+ tracker.clear();
+
+ t.is(tracker.getPatterns().length, 0);
+ t.is(tracker.getHistory().length, 0);
+ t.is(tracker.getCurrentChain().length, 0);
+});
+
+test('SequenceTracker counts non-overlapping repeats and does not inflate single run of identical tools', t => {
+ const tracker = new SequenceTracker({minOccurrencesForCandidate: 2});
+
+ // A single run of four read_file calls
+ for (let i = 0; i < 4; i++) {
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: `file_${i}.ts`},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ }
+
+ // Should not be reported as a repeated candidate in a single run
+ const patterns = tracker.getPatterns(1);
+ const readRepeatPattern = patterns.find(
+ p => p.signature === 'read_file -> read_file',
+ );
+ t.truthy(readRepeatPattern);
+ t.is(readRepeatPattern?.occurrences, 1);
+ t.is(tracker.getCandidates().length, 0);
+
+ // A second separate streak of read_file calls (e.g. after a chain break / new turn)
+ tracker.breakChain();
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: 'other_1.ts'},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+ tracker.recordExecution({
+ toolName: 'read_file',
+ inputArgs: {path: 'other_2.ts'},
+ success: true,
+ readOnly: true,
+ timestamp: Date.now(),
+ });
+
+ // Now it has occurred in 2 separate runs and becomes a candidate
+ const candidates = tracker.getCandidates();
+ t.is(candidates.length, 1);
+ t.is(candidates[0].signature, 'read_file -> read_file');
+ t.is(candidates[0].occurrences, 2);
+});
diff --git a/source/macros/sequence-tracker.ts b/source/macros/sequence-tracker.ts
new file mode 100644
index 000000000..6d0c09289
--- /dev/null
+++ b/source/macros/sequence-tracker.ts
@@ -0,0 +1,178 @@
+import type {ToolExecutionRecord, WorkflowPattern} from '@/types/macros';
+
+export interface SequenceTrackerOptions {
+ maxHistory?: number;
+ minSequenceLength?: number;
+ maxSequenceLength?: number;
+ minOccurrencesForCandidate?: number;
+}
+
+export class SequenceTracker {
+ private readonly history: ToolExecutionRecord[] = [];
+ private currentChain: ToolExecutionRecord[] = [];
+ private readonly patterns: Map = new Map();
+ private readonly patternLastEndIndex: Map = new Map();
+ private readonly patternLastStreakId: Map = new Map();
+ private currentStreakTool: string | null = null;
+ private currentStreakId = 0;
+ private executionCounter = 0;
+ private readonly maxHistory: number;
+ private readonly minSeqLen: number;
+ private readonly maxSeqLen: number;
+ private readonly minOccurrences: number;
+
+ constructor(options?: SequenceTrackerOptions) {
+ this.maxHistory = options?.maxHistory ?? 100;
+ this.minSeqLen = options?.minSequenceLength ?? 2;
+ this.maxSeqLen = options?.maxSequenceLength ?? 5;
+ this.minOccurrences = options?.minOccurrencesForCandidate ?? 2;
+ }
+
+ recordExecution(record: ToolExecutionRecord): void {
+ this.history.push(record);
+ if (this.history.length > this.maxHistory) {
+ this.history.shift();
+ }
+
+ // Only successful read-only actions form repeatable macro chains
+ if (!record.success || !record.readOnly) {
+ this.breakChain();
+ return;
+ }
+
+ if (record.toolName === this.currentStreakTool) {
+ // Continues current streak of the same tool
+ } else {
+ this.currentStreakTool = record.toolName;
+ this.currentStreakId += 1;
+ }
+
+ const currentIndex = this.executionCounter;
+ this.executionCounter += 1;
+
+ this.currentChain.push(record);
+ if (this.currentChain.length > this.maxSeqLen) {
+ this.currentChain.shift();
+ }
+
+ this.extractPatternsFromCurrentChain(currentIndex);
+ }
+
+ /**
+ * Explicitly breaks the active contiguous chain of read-only tool calls.
+ * Interleaved mutating actions, failed calls, and agent subagent batches
+ * invoke this so they do not falsely link independent read-only calls.
+ */
+ breakChain(): void {
+ this.currentChain = [];
+ this.currentStreakTool = null;
+ this.currentStreakId += 1;
+ }
+
+ private extractPatternsFromCurrentChain(currentIndex: number): void {
+ const chainLen = this.currentChain.length;
+ for (
+ let len = this.minSeqLen;
+ len <= this.maxSeqLen && len <= chainLen;
+ len++
+ ) {
+ const slice = this.currentChain.slice(chainLen - len);
+ const sequence = slice.map(r => r.toolName);
+ const signature = sequence.join(' -> ');
+ const exemplarRecords = slice.map(r => ({
+ ...r,
+ inputArgs: {...r.inputArgs},
+ }));
+
+ const startIndex = currentIndex - len + 1;
+ const lastEnd = this.patternLastEndIndex.get(signature) ?? -1;
+
+ // Count non-overlapping repeats: slice must start after the end of the previous match
+ if (startIndex <= lastEnd) {
+ continue;
+ }
+
+ // If all tools in the sequence are identical (e.g. read_file -> read_file),
+ // require that it comes from a separate streak/run so a single run of calling
+ // the same tool repeatedly does not falsely report as a repeated macro.
+ const isAllSameTool = sequence.every(name => name === sequence[0]);
+ if (isAllSameTool) {
+ const lastStreak = this.patternLastStreakId.get(signature);
+ if (lastStreak !== undefined && lastStreak === this.currentStreakId) {
+ continue;
+ }
+ this.patternLastStreakId.set(signature, this.currentStreakId);
+ }
+
+ this.patternLastEndIndex.set(signature, currentIndex);
+
+ const existing = this.patterns.get(signature);
+ if (existing) {
+ existing.occurrences += 1;
+ existing.lastExecutedAt = Date.now();
+ existing.exemplarRecords = exemplarRecords;
+ } else {
+ this.patterns.set(signature, {
+ id: `pattern_${Math.abs(hashString(signature))}`,
+ signature,
+ sequence,
+ occurrences: 1,
+ lastExecutedAt: Date.now(),
+ exemplarRecords,
+ });
+ }
+ }
+ }
+
+ getPatterns(minOccurrences?: number): WorkflowPattern[] {
+ const threshold = minOccurrences ?? 1;
+ return Array.from(this.patterns.values())
+ .filter(p => p.occurrences >= threshold)
+ .sort((a, b) => b.occurrences - a.occurrences);
+ }
+
+ getCandidates(): WorkflowPattern[] {
+ return this.getPatterns(this.minOccurrences);
+ }
+
+ getHistory(): ReadonlyArray {
+ return [...this.history];
+ }
+
+ getCurrentChain(): ReadonlyArray {
+ return [...this.currentChain];
+ }
+
+ clear(): void {
+ this.history.length = 0;
+ this.currentChain = [];
+ this.patterns.clear();
+ this.patternLastEndIndex.clear();
+ this.patternLastStreakId.clear();
+ this.currentStreakTool = null;
+ this.currentStreakId = 0;
+ this.executionCounter = 0;
+ }
+}
+
+function hashString(str: string): number {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = (hash << 5) - hash + str.charCodeAt(i);
+ hash |= 0;
+ }
+ return hash;
+}
+
+let globalSequenceTracker: SequenceTracker | null = null;
+
+export function getSequenceTracker(): SequenceTracker {
+ if (!globalSequenceTracker) {
+ globalSequenceTracker = new SequenceTracker();
+ }
+ return globalSequenceTracker;
+}
+
+export function resetSequenceTracker(): void {
+ globalSequenceTracker = null;
+}
diff --git a/source/types/macros.ts b/source/types/macros.ts
new file mode 100644
index 000000000..78d70f7a5
--- /dev/null
+++ b/source/types/macros.ts
@@ -0,0 +1,18 @@
+export interface ToolExecutionRecord {
+ toolName: string;
+ inputArgs: Record;
+ success: boolean;
+ readOnly: boolean;
+ parallelBatch?: boolean;
+ durationMs?: number;
+ timestamp: number;
+}
+
+export interface WorkflowPattern {
+ id: string;
+ signature: string;
+ sequence: string[];
+ occurrences: number;
+ lastExecutedAt: number;
+ exemplarRecords?: ToolExecutionRecord[];
+}