clementine-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- package/vault/06-Templates/_People-Template.md +22 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Tool loop detection system.
|
|
3
|
+
*
|
|
4
|
+
* Detects when the agent gets stuck in repetitive tool-call patterns:
|
|
5
|
+
* - generic_repeat: Same tool+input called repeatedly
|
|
6
|
+
* - poll_no_progress: Same tool returning identical results
|
|
7
|
+
* - ping_pong: Alternating between two tool+input combos with no result change
|
|
8
|
+
*
|
|
9
|
+
* Inspired by OpenClaw's loop detection approach.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import pino from 'pino';
|
|
13
|
+
// ── Constants (configurable) ────────────────────────────────────────
|
|
14
|
+
/** Maximum number of tool calls to keep in the sliding window. */
|
|
15
|
+
const WINDOW_SIZE = 30;
|
|
16
|
+
/** Entries older than this are pruned on each call. */
|
|
17
|
+
const WINDOW_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
18
|
+
/** generic_repeat: Same tool+inputHash called N times. */
|
|
19
|
+
const GENERIC_REPEAT_WARN = 5;
|
|
20
|
+
const GENERIC_REPEAT_BLOCK = 8;
|
|
21
|
+
/** poll_no_progress: Same tool returning identical resultHash N times. */
|
|
22
|
+
const POLL_NO_PROGRESS_WARN = 4;
|
|
23
|
+
const POLL_NO_PROGRESS_BLOCK = 7;
|
|
24
|
+
/** ping_pong: Alternating between exactly two tool+inputHash combos (pairs). */
|
|
25
|
+
const PING_PONG_WARN_PAIRS = 3; // 6 calls
|
|
26
|
+
const PING_PONG_BLOCK_PAIRS = 5; // 10 calls
|
|
27
|
+
// ── Logger ──────────────────────────────────────────────────────────
|
|
28
|
+
const logger = pino({ name: 'clementine.tool-loop-detector' });
|
|
29
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
30
|
+
/** Compute a truncated SHA-256 hex digest (first 16 chars). */
|
|
31
|
+
function shortHash(data) {
|
|
32
|
+
return createHash('sha256').update(data).digest('hex').slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
/** Composite key for a tool call: tool name + input hash. */
|
|
35
|
+
function callKey(entry) {
|
|
36
|
+
return `${entry.toolName}:${entry.inputHash}`;
|
|
37
|
+
}
|
|
38
|
+
// ── ToolLoopDetector ────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Detects repetitive tool-call patterns in a sliding window.
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
* 1. Call `recordCall()` before each tool invocation — it returns a verdict.
|
|
44
|
+
* 2. Call `recordResult()` after the tool returns — it updates the last entry's resultHash.
|
|
45
|
+
* 3. Call `reset()` when rotating sessions to clear state.
|
|
46
|
+
*/
|
|
47
|
+
export class ToolLoopDetector {
|
|
48
|
+
window = [];
|
|
49
|
+
/**
|
|
50
|
+
* Record a new tool call, run all detectors, and return the verdict.
|
|
51
|
+
*
|
|
52
|
+
* @param toolName - Name of the tool being called
|
|
53
|
+
* @param input - Tool input parameters
|
|
54
|
+
* @returns Loop check result with verdict and optional detector/detail info
|
|
55
|
+
*/
|
|
56
|
+
recordCall(toolName, input) {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
// Prune entries older than TTL
|
|
59
|
+
this.window = this.window.filter((e) => now - e.timestamp < WINDOW_TTL_MS);
|
|
60
|
+
const inputHash = shortHash(JSON.stringify(input));
|
|
61
|
+
const entry = {
|
|
62
|
+
toolName,
|
|
63
|
+
inputHash,
|
|
64
|
+
resultHash: '', // filled in by recordResult()
|
|
65
|
+
timestamp: now,
|
|
66
|
+
};
|
|
67
|
+
this.window.push(entry);
|
|
68
|
+
// Trim to max window size (keep most recent)
|
|
69
|
+
if (this.window.length > WINDOW_SIZE) {
|
|
70
|
+
this.window = this.window.slice(this.window.length - WINDOW_SIZE);
|
|
71
|
+
}
|
|
72
|
+
// Run detectors in order of severity (block takes precedence)
|
|
73
|
+
const results = [
|
|
74
|
+
this.detectGenericRepeat(),
|
|
75
|
+
this.detectPollNoProgress(),
|
|
76
|
+
this.detectPingPong(),
|
|
77
|
+
];
|
|
78
|
+
// Return the most severe result
|
|
79
|
+
const block = results.find((r) => r.verdict === 'block');
|
|
80
|
+
if (block) {
|
|
81
|
+
logger.warn({ detector: block.detector, detail: block.detail }, 'Tool loop blocked');
|
|
82
|
+
return block;
|
|
83
|
+
}
|
|
84
|
+
const warn = results.find((r) => r.verdict === 'warn');
|
|
85
|
+
if (warn) {
|
|
86
|
+
logger.info({ detector: warn.detector, detail: warn.detail }, 'Tool loop warning');
|
|
87
|
+
return warn;
|
|
88
|
+
}
|
|
89
|
+
return { verdict: 'ok' };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Update the most recent call entry with the result hash.
|
|
93
|
+
*
|
|
94
|
+
* @param resultText - The text output returned by the tool
|
|
95
|
+
*/
|
|
96
|
+
recordResult(resultText) {
|
|
97
|
+
if (this.window.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
this.window[this.window.length - 1].resultHash = shortHash(resultText);
|
|
100
|
+
}
|
|
101
|
+
/** Clear the sliding window (e.g. on session rotation). */
|
|
102
|
+
reset() {
|
|
103
|
+
this.window = [];
|
|
104
|
+
}
|
|
105
|
+
// ── Detectors ───────────────────────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* generic_repeat: Same tool+inputHash called N times in the window.
|
|
108
|
+
*/
|
|
109
|
+
detectGenericRepeat() {
|
|
110
|
+
if (this.window.length === 0)
|
|
111
|
+
return { verdict: 'ok' };
|
|
112
|
+
const latest = this.window[this.window.length - 1];
|
|
113
|
+
const key = callKey(latest);
|
|
114
|
+
let count = 0;
|
|
115
|
+
for (const entry of this.window) {
|
|
116
|
+
if (callKey(entry) === key)
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
if (count >= GENERIC_REPEAT_BLOCK) {
|
|
120
|
+
return {
|
|
121
|
+
verdict: 'block',
|
|
122
|
+
detector: 'generic_repeat',
|
|
123
|
+
detail: `Tool ${latest.toolName} called ${count} times with identical input (threshold: ${GENERIC_REPEAT_BLOCK})`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (count >= GENERIC_REPEAT_WARN) {
|
|
127
|
+
return {
|
|
128
|
+
verdict: 'warn',
|
|
129
|
+
detector: 'generic_repeat',
|
|
130
|
+
detail: `Tool ${latest.toolName} called ${count} times with identical input (warn threshold: ${GENERIC_REPEAT_WARN})`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { verdict: 'ok' };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* poll_no_progress: Same tool returning identical resultHash N times.
|
|
137
|
+
* Only considers entries that have a resultHash set.
|
|
138
|
+
*/
|
|
139
|
+
detectPollNoProgress() {
|
|
140
|
+
if (this.window.length === 0)
|
|
141
|
+
return { verdict: 'ok' };
|
|
142
|
+
const latest = this.window[this.window.length - 1];
|
|
143
|
+
const key = callKey(latest);
|
|
144
|
+
// Gather entries with matching call key that have results
|
|
145
|
+
const withResults = this.window.filter((e) => callKey(e) === key && e.resultHash !== '');
|
|
146
|
+
if (withResults.length === 0)
|
|
147
|
+
return { verdict: 'ok' };
|
|
148
|
+
// Count how many share the most common resultHash
|
|
149
|
+
const resultCounts = new Map();
|
|
150
|
+
for (const e of withResults) {
|
|
151
|
+
resultCounts.set(e.resultHash, (resultCounts.get(e.resultHash) ?? 0) + 1);
|
|
152
|
+
}
|
|
153
|
+
const maxCount = Math.max(...resultCounts.values());
|
|
154
|
+
if (maxCount >= POLL_NO_PROGRESS_BLOCK) {
|
|
155
|
+
return {
|
|
156
|
+
verdict: 'block',
|
|
157
|
+
detector: 'poll_no_progress',
|
|
158
|
+
detail: `Tool ${latest.toolName} returned identical results ${maxCount} times (threshold: ${POLL_NO_PROGRESS_BLOCK})`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (maxCount >= POLL_NO_PROGRESS_WARN) {
|
|
162
|
+
return {
|
|
163
|
+
verdict: 'warn',
|
|
164
|
+
detector: 'poll_no_progress',
|
|
165
|
+
detail: `Tool ${latest.toolName} returned identical results ${maxCount} times (warn threshold: ${POLL_NO_PROGRESS_WARN})`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return { verdict: 'ok' };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* ping_pong: Alternating between exactly two tool+inputHash combos
|
|
172
|
+
* with no result change. Scans the tail of the window for the pattern.
|
|
173
|
+
*/
|
|
174
|
+
detectPingPong() {
|
|
175
|
+
const w = this.window;
|
|
176
|
+
if (w.length < 4)
|
|
177
|
+
return { verdict: 'ok' };
|
|
178
|
+
// Work backwards from the end — look for A-B-A-B pattern
|
|
179
|
+
const a = callKey(w[w.length - 2]);
|
|
180
|
+
const b = callKey(w[w.length - 1]);
|
|
181
|
+
// Must be two distinct combos
|
|
182
|
+
if (a === b)
|
|
183
|
+
return { verdict: 'ok' };
|
|
184
|
+
// Count consecutive alternating pairs from the end
|
|
185
|
+
let pairs = 0;
|
|
186
|
+
let resultsStatic = true;
|
|
187
|
+
for (let i = w.length - 1; i >= 1; i -= 2) {
|
|
188
|
+
const curKey = callKey(w[i]);
|
|
189
|
+
const prevKey = callKey(w[i - 1]);
|
|
190
|
+
if (curKey === b && prevKey === a) {
|
|
191
|
+
pairs++;
|
|
192
|
+
// Check if results are changing (only for entries with results)
|
|
193
|
+
if (w[i].resultHash !== '' && w[i - 1].resultHash !== '') {
|
|
194
|
+
// Check if the B results differ across pairs, or A results differ
|
|
195
|
+
// We consider it "static" if same-key entries all share the same resultHash
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Verify results are static across the alternating calls
|
|
203
|
+
if (pairs >= PING_PONG_WARN_PAIRS) {
|
|
204
|
+
const startIdx = w.length - pairs * 2;
|
|
205
|
+
const aResults = new Set();
|
|
206
|
+
const bResults = new Set();
|
|
207
|
+
for (let i = startIdx; i < w.length; i += 2) {
|
|
208
|
+
if (w[i].resultHash !== '')
|
|
209
|
+
aResults.add(w[i].resultHash);
|
|
210
|
+
if (i + 1 < w.length && w[i + 1].resultHash !== '')
|
|
211
|
+
bResults.add(w[i + 1].resultHash);
|
|
212
|
+
}
|
|
213
|
+
// If results vary, it's not a stuck loop
|
|
214
|
+
resultsStatic = aResults.size <= 1 && bResults.size <= 1;
|
|
215
|
+
}
|
|
216
|
+
if (!resultsStatic)
|
|
217
|
+
return { verdict: 'ok' };
|
|
218
|
+
if (pairs >= PING_PONG_BLOCK_PAIRS) {
|
|
219
|
+
const toolA = w[w.length - 2].toolName;
|
|
220
|
+
const toolB = w[w.length - 1].toolName;
|
|
221
|
+
return {
|
|
222
|
+
verdict: 'block',
|
|
223
|
+
detector: 'ping_pong',
|
|
224
|
+
detail: `Alternating between ${toolA} and ${toolB} for ${pairs} pairs (${pairs * 2} calls, threshold: ${PING_PONG_BLOCK_PAIRS} pairs)`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (pairs >= PING_PONG_WARN_PAIRS) {
|
|
228
|
+
const toolA = w[w.length - 2].toolName;
|
|
229
|
+
const toolB = w[w.length - 1].toolName;
|
|
230
|
+
return {
|
|
231
|
+
verdict: 'warn',
|
|
232
|
+
detector: 'ping_pong',
|
|
233
|
+
detail: `Alternating between ${toolA} and ${toolB} for ${pairs} pairs (${pairs * 2} calls, warn threshold: ${PING_PONG_WARN_PAIRS} pairs)`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return { verdict: 'ok' };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// ── Singleton ───────────────────────────────────────────────────────
|
|
240
|
+
/** Shared singleton instance for the process. */
|
|
241
|
+
export const toolLoopDetector = new ToolLoopDetector();
|
|
242
|
+
//# sourceMappingURL=tool-loop-detector.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Workflow Runner.
|
|
3
|
+
*
|
|
4
|
+
* Parses workflow definition files (markdown + YAML frontmatter),
|
|
5
|
+
* validates the step DAG, and executes steps using the existing
|
|
6
|
+
* PlanOrchestrator primitives (computeWaves + settledWithLimit).
|
|
7
|
+
*/
|
|
8
|
+
import type { PersonalAssistant } from './assistant.js';
|
|
9
|
+
import type { WorkflowDefinition, WorkflowRunEntry } from '../types.js';
|
|
10
|
+
export interface WorkflowProgressUpdate {
|
|
11
|
+
stepId: string;
|
|
12
|
+
status: 'waiting' | 'running' | 'done' | 'failed' | 'skipped';
|
|
13
|
+
description: string;
|
|
14
|
+
durationMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface WorkflowRunResult {
|
|
17
|
+
status: 'ok' | 'error' | 'partial';
|
|
18
|
+
output: string;
|
|
19
|
+
entry: WorkflowRunEntry;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse a single workflow markdown file into a WorkflowDefinition.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseWorkflow(filePath: string): WorkflowDefinition;
|
|
25
|
+
/**
|
|
26
|
+
* Parse all workflow files in a directory.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseAllWorkflows(dir: string): WorkflowDefinition[];
|
|
29
|
+
export declare class WorkflowRunner {
|
|
30
|
+
private assistant;
|
|
31
|
+
constructor(assistant: PersonalAssistant);
|
|
32
|
+
run(workflow: WorkflowDefinition, inputs: Record<string, string>, onProgress?: (updates: WorkflowProgressUpdate[]) => void): Promise<WorkflowRunResult>;
|
|
33
|
+
private buildSynthesisPrompt;
|
|
34
|
+
private fallbackOutput;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=workflow-runner.d.ts.map
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Workflow Runner.
|
|
3
|
+
*
|
|
4
|
+
* Parses workflow definition files (markdown + YAML frontmatter),
|
|
5
|
+
* validates the step DAG, and executes steps using the existing
|
|
6
|
+
* PlanOrchestrator primitives (computeWaves + settledWithLimit).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, appendFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import matter from 'gray-matter';
|
|
12
|
+
import pino from 'pino';
|
|
13
|
+
import { computeWaves, settledWithLimit } from './orchestrator.js';
|
|
14
|
+
import { resolveStaticVariables, resolveStepOutputs } from './workflow-variables.js';
|
|
15
|
+
import { BASE_DIR } from '../config.js';
|
|
16
|
+
const logger = pino({ name: 'clementine.workflow' });
|
|
17
|
+
const MAX_CONCURRENT_STEPS = 3;
|
|
18
|
+
const RESULT_TRUNCATE_CHARS = 4000;
|
|
19
|
+
// ── Parsing ─────────────────────────────────────────────────────────
|
|
20
|
+
/**
|
|
21
|
+
* Parse a single workflow markdown file into a WorkflowDefinition.
|
|
22
|
+
*/
|
|
23
|
+
export function parseWorkflow(filePath) {
|
|
24
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
25
|
+
const parsed = matter(raw);
|
|
26
|
+
const data = parsed.data;
|
|
27
|
+
if (data.type !== 'workflow') {
|
|
28
|
+
throw new Error(`File is not a workflow (type="${data.type}"): ${filePath}`);
|
|
29
|
+
}
|
|
30
|
+
const name = String(data.name ?? path.basename(filePath, '.md'));
|
|
31
|
+
const description = String(data.description ?? '');
|
|
32
|
+
const enabled = data.enabled !== false;
|
|
33
|
+
// Trigger
|
|
34
|
+
const triggerRaw = data.trigger ?? {};
|
|
35
|
+
const trigger = {
|
|
36
|
+
schedule: triggerRaw.schedule ? String(triggerRaw.schedule) : undefined,
|
|
37
|
+
manual: triggerRaw.manual !== false,
|
|
38
|
+
};
|
|
39
|
+
// Inputs
|
|
40
|
+
const inputs = {};
|
|
41
|
+
if (data.inputs && typeof data.inputs === 'object') {
|
|
42
|
+
for (const [key, val] of Object.entries(data.inputs)) {
|
|
43
|
+
const v = val;
|
|
44
|
+
inputs[key] = {
|
|
45
|
+
type: (v.type === 'number' ? 'number' : 'string'),
|
|
46
|
+
default: v.default != null ? String(v.default) : undefined,
|
|
47
|
+
description: v.description ? String(v.description) : undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Steps
|
|
52
|
+
const stepsRaw = data.steps;
|
|
53
|
+
if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) {
|
|
54
|
+
throw new Error(`Workflow "${name}" has no steps: ${filePath}`);
|
|
55
|
+
}
|
|
56
|
+
const steps = stepsRaw.map((s, i) => ({
|
|
57
|
+
id: String(s.id ?? `step-${i + 1}`),
|
|
58
|
+
prompt: String(s.prompt ?? ''),
|
|
59
|
+
dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn.map(String) : [],
|
|
60
|
+
model: s.model ? String(s.model) : undefined,
|
|
61
|
+
tier: Number(s.tier ?? 1),
|
|
62
|
+
maxTurns: Number(s.maxTurns ?? 15),
|
|
63
|
+
workDir: s.workDir ? String(s.workDir) : undefined,
|
|
64
|
+
}));
|
|
65
|
+
// Synthesis
|
|
66
|
+
const synthesis = data.synthesis?.prompt
|
|
67
|
+
? { prompt: String(data.synthesis.prompt) }
|
|
68
|
+
: undefined;
|
|
69
|
+
return {
|
|
70
|
+
name,
|
|
71
|
+
description,
|
|
72
|
+
enabled,
|
|
73
|
+
trigger,
|
|
74
|
+
inputs,
|
|
75
|
+
steps,
|
|
76
|
+
synthesis,
|
|
77
|
+
sourceFile: filePath,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse all workflow files in a directory.
|
|
82
|
+
*/
|
|
83
|
+
export function parseAllWorkflows(dir) {
|
|
84
|
+
if (!existsSync(dir))
|
|
85
|
+
return [];
|
|
86
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
87
|
+
const workflows = [];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
try {
|
|
90
|
+
workflows.push(parseWorkflow(path.join(dir, file)));
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
logger.warn({ err, file }, `Failed to parse workflow: ${file}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return workflows;
|
|
97
|
+
}
|
|
98
|
+
// ── Validation ──────────────────────────────────────────────────────
|
|
99
|
+
function validateWorkflow(workflow) {
|
|
100
|
+
const errors = [];
|
|
101
|
+
const stepIds = new Set(workflow.steps.map(s => s.id));
|
|
102
|
+
// Check unique IDs
|
|
103
|
+
if (stepIds.size !== workflow.steps.length) {
|
|
104
|
+
errors.push('Duplicate step IDs found');
|
|
105
|
+
}
|
|
106
|
+
// Check dependencies exist
|
|
107
|
+
for (const step of workflow.steps) {
|
|
108
|
+
for (const dep of step.dependsOn) {
|
|
109
|
+
if (!stepIds.has(dep)) {
|
|
110
|
+
errors.push(`Step "${step.id}" depends on unknown step "${dep}"`);
|
|
111
|
+
}
|
|
112
|
+
if (dep === step.id) {
|
|
113
|
+
errors.push(`Step "${step.id}" depends on itself`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!step.prompt.trim()) {
|
|
117
|
+
errors.push(`Step "${step.id}" has an empty prompt`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Check for cycles (computeWaves will throw on cycles)
|
|
121
|
+
try {
|
|
122
|
+
const planSteps = workflow.steps.map(s => ({
|
|
123
|
+
id: s.id,
|
|
124
|
+
description: s.id,
|
|
125
|
+
prompt: s.prompt,
|
|
126
|
+
dependsOn: s.dependsOn,
|
|
127
|
+
maxTurns: s.maxTurns,
|
|
128
|
+
tier: s.tier,
|
|
129
|
+
}));
|
|
130
|
+
computeWaves(planSteps);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
errors.push('Circular dependency detected in steps');
|
|
134
|
+
}
|
|
135
|
+
return errors;
|
|
136
|
+
}
|
|
137
|
+
// ── Run log ─────────────────────────────────────────────────────────
|
|
138
|
+
function getRunLogDir() {
|
|
139
|
+
const dir = path.join(BASE_DIR, 'workflows', 'runs');
|
|
140
|
+
if (!existsSync(dir))
|
|
141
|
+
mkdirSync(dir, { recursive: true });
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
function appendRunLog(entry) {
|
|
145
|
+
const safe = entry.workflowName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
146
|
+
const filePath = path.join(getRunLogDir(), `${safe}.jsonl`);
|
|
147
|
+
try {
|
|
148
|
+
appendFileSync(filePath, JSON.stringify(entry) + '\n');
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
logger.warn({ err, workflow: entry.workflowName }, 'Failed to write workflow run log');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ── WorkflowRunner ──────────────────────────────────────────────────
|
|
155
|
+
export class WorkflowRunner {
|
|
156
|
+
assistant;
|
|
157
|
+
constructor(assistant) {
|
|
158
|
+
this.assistant = assistant;
|
|
159
|
+
}
|
|
160
|
+
async run(workflow, inputs, onProgress) {
|
|
161
|
+
const runId = randomUUID().slice(0, 8);
|
|
162
|
+
const startTime = Date.now();
|
|
163
|
+
logger.info({ workflow: workflow.name, runId, inputs }, 'Starting workflow');
|
|
164
|
+
// 1. Validate
|
|
165
|
+
const errors = validateWorkflow(workflow);
|
|
166
|
+
if (errors.length > 0) {
|
|
167
|
+
const entry = {
|
|
168
|
+
workflowName: workflow.name,
|
|
169
|
+
runId,
|
|
170
|
+
startedAt: new Date(startTime).toISOString(),
|
|
171
|
+
finishedAt: new Date().toISOString(),
|
|
172
|
+
status: 'error',
|
|
173
|
+
durationMs: Date.now() - startTime,
|
|
174
|
+
inputs,
|
|
175
|
+
stepResults: [],
|
|
176
|
+
error: `Validation failed: ${errors.join('; ')}`,
|
|
177
|
+
};
|
|
178
|
+
appendRunLog(entry);
|
|
179
|
+
return { status: 'error', output: `Workflow validation failed:\n${errors.join('\n')}`, entry };
|
|
180
|
+
}
|
|
181
|
+
// 2. Merge inputs with defaults
|
|
182
|
+
const resolvedInputs = {};
|
|
183
|
+
for (const [key, def] of Object.entries(workflow.inputs)) {
|
|
184
|
+
resolvedInputs[key] = inputs[key] ?? def.default ?? '';
|
|
185
|
+
}
|
|
186
|
+
// Also pass through any extra inputs not in the schema
|
|
187
|
+
for (const [key, val] of Object.entries(inputs)) {
|
|
188
|
+
if (!(key in resolvedInputs))
|
|
189
|
+
resolvedInputs[key] = val;
|
|
190
|
+
}
|
|
191
|
+
// 3. Resolve static variables in all step prompts
|
|
192
|
+
const resolvedSteps = workflow.steps.map(s => ({
|
|
193
|
+
...s,
|
|
194
|
+
prompt: resolveStaticVariables(s.prompt, resolvedInputs, workflow.name),
|
|
195
|
+
}));
|
|
196
|
+
// 4. Compute execution waves
|
|
197
|
+
const planSteps = resolvedSteps.map(s => ({
|
|
198
|
+
id: s.id,
|
|
199
|
+
description: s.id,
|
|
200
|
+
prompt: s.prompt,
|
|
201
|
+
dependsOn: s.dependsOn,
|
|
202
|
+
maxTurns: s.maxTurns,
|
|
203
|
+
tier: s.tier,
|
|
204
|
+
model: s.model,
|
|
205
|
+
}));
|
|
206
|
+
const waves = computeWaves(planSteps);
|
|
207
|
+
// 5. Initialize progress
|
|
208
|
+
const statuses = new Map();
|
|
209
|
+
for (const step of resolvedSteps) {
|
|
210
|
+
statuses.set(step.id, { stepId: step.id, status: 'waiting', description: step.id });
|
|
211
|
+
}
|
|
212
|
+
onProgress?.([...statuses.values()]);
|
|
213
|
+
// 6. Execute waves
|
|
214
|
+
const stepResults = new Map();
|
|
215
|
+
const stepEntries = [];
|
|
216
|
+
let hasFailures = false;
|
|
217
|
+
for (const wave of waves) {
|
|
218
|
+
// Mark running
|
|
219
|
+
for (const step of wave) {
|
|
220
|
+
statuses.set(step.id, { stepId: step.id, status: 'running', description: step.id });
|
|
221
|
+
}
|
|
222
|
+
onProgress?.([...statuses.values()]);
|
|
223
|
+
// Run wave
|
|
224
|
+
const settled = await settledWithLimit(wave.map(step => async () => {
|
|
225
|
+
const resolvedStep = resolvedSteps.find(s => s.id === step.id);
|
|
226
|
+
// Resolve step output references
|
|
227
|
+
const prompt = resolveStepOutputs(resolvedStep.prompt, stepResults, RESULT_TRUNCATE_CHARS);
|
|
228
|
+
const stepStart = Date.now();
|
|
229
|
+
const result = await this.assistant.runPlanStep(step.id, prompt, {
|
|
230
|
+
tier: resolvedStep.tier,
|
|
231
|
+
maxTurns: resolvedStep.maxTurns,
|
|
232
|
+
model: resolvedStep.model,
|
|
233
|
+
});
|
|
234
|
+
return { stepId: step.id, result, durationMs: Date.now() - stepStart };
|
|
235
|
+
}), MAX_CONCURRENT_STEPS);
|
|
236
|
+
// Collect results
|
|
237
|
+
for (let i = 0; i < wave.length; i++) {
|
|
238
|
+
const step = wave[i];
|
|
239
|
+
const outcome = settled[i];
|
|
240
|
+
if (outcome.status === 'fulfilled') {
|
|
241
|
+
const { result, durationMs } = outcome.value;
|
|
242
|
+
const output = result || '[No output produced]';
|
|
243
|
+
stepResults.set(step.id, output);
|
|
244
|
+
statuses.set(step.id, {
|
|
245
|
+
stepId: step.id, status: 'done', description: step.id, durationMs,
|
|
246
|
+
});
|
|
247
|
+
stepEntries.push({
|
|
248
|
+
stepId: step.id, status: 'done', durationMs,
|
|
249
|
+
outputPreview: output.slice(0, 200),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const errMsg = `[FAILED: ${outcome.reason}]`;
|
|
254
|
+
stepResults.set(step.id, errMsg);
|
|
255
|
+
hasFailures = true;
|
|
256
|
+
statuses.set(step.id, {
|
|
257
|
+
stepId: step.id, status: 'failed', description: step.id,
|
|
258
|
+
});
|
|
259
|
+
stepEntries.push({ stepId: step.id, status: 'failed', durationMs: 0 });
|
|
260
|
+
logger.error({ stepId: step.id, err: outcome.reason }, 'Workflow step failed');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
onProgress?.([...statuses.values()]);
|
|
264
|
+
}
|
|
265
|
+
// 7. Synthesis
|
|
266
|
+
let finalOutput;
|
|
267
|
+
if (workflow.synthesis?.prompt) {
|
|
268
|
+
const synthPrompt = this.buildSynthesisPrompt(workflow, stepResults);
|
|
269
|
+
try {
|
|
270
|
+
finalOutput = await this.assistant.runPlanStep('__synthesis__', synthPrompt, {
|
|
271
|
+
tier: 2, maxTurns: 5, disableTools: true,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
logger.warn({ err }, 'Workflow synthesis failed — concatenating results');
|
|
276
|
+
finalOutput = this.fallbackOutput(stepResults);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
finalOutput = this.fallbackOutput(stepResults);
|
|
281
|
+
}
|
|
282
|
+
// 8. Log
|
|
283
|
+
const entry = {
|
|
284
|
+
workflowName: workflow.name,
|
|
285
|
+
runId,
|
|
286
|
+
startedAt: new Date(startTime).toISOString(),
|
|
287
|
+
finishedAt: new Date().toISOString(),
|
|
288
|
+
status: hasFailures ? 'partial' : 'ok',
|
|
289
|
+
durationMs: Date.now() - startTime,
|
|
290
|
+
inputs: resolvedInputs,
|
|
291
|
+
stepResults: stepEntries,
|
|
292
|
+
outputPreview: finalOutput.slice(0, 500),
|
|
293
|
+
};
|
|
294
|
+
appendRunLog(entry);
|
|
295
|
+
logger.info({ workflow: workflow.name, runId, status: entry.status, durationMs: entry.durationMs }, 'Workflow completed');
|
|
296
|
+
return { status: entry.status, output: finalOutput, entry };
|
|
297
|
+
}
|
|
298
|
+
buildSynthesisPrompt(workflow, stepResults) {
|
|
299
|
+
const parts = [`## Workflow: ${workflow.name}\n`];
|
|
300
|
+
parts.push('## Step results:');
|
|
301
|
+
for (const step of workflow.steps) {
|
|
302
|
+
const result = stepResults.get(step.id) ?? '[no result]';
|
|
303
|
+
const truncated = result.length > RESULT_TRUNCATE_CHARS
|
|
304
|
+
? result.slice(0, RESULT_TRUNCATE_CHARS) + '\n...[truncated]'
|
|
305
|
+
: result;
|
|
306
|
+
parts.push(`### ${step.id}\n${truncated}`);
|
|
307
|
+
}
|
|
308
|
+
parts.push(`\n## Instructions:\n${workflow.synthesis.prompt}`);
|
|
309
|
+
return parts.join('\n');
|
|
310
|
+
}
|
|
311
|
+
fallbackOutput(stepResults) {
|
|
312
|
+
return Array.from(stepResults.entries())
|
|
313
|
+
.map(([id, result]) => `**${id}:**\n${result.slice(0, 500)}`)
|
|
314
|
+
.join('\n\n');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
//# sourceMappingURL=workflow-runner.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Workflow template variable resolution.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for resolving {{...}} placeholders in workflow step prompts.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Resolve static variables (available before any step executes).
|
|
8
|
+
* Handles: {{input.*}}, {{env.*}}, {{date}}, {{time}}, {{workflow.*}}
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveStaticVariables(template: string, inputs: Record<string, string>, workflowName: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve step output references after dependencies complete.
|
|
13
|
+
* Handles: {{steps.research.output}}
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveStepOutputs(template: string, stepResults: Map<string, string>, truncateChars?: number): string;
|
|
16
|
+
//# sourceMappingURL=workflow-variables.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Workflow template variable resolution.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for resolving {{...}} placeholders in workflow step prompts.
|
|
5
|
+
*/
|
|
6
|
+
import { OWNER_NAME, ASSISTANT_NAME } from '../config.js';
|
|
7
|
+
/**
|
|
8
|
+
* Resolve static variables (available before any step executes).
|
|
9
|
+
* Handles: {{input.*}}, {{env.*}}, {{date}}, {{time}}, {{workflow.*}}
|
|
10
|
+
*/
|
|
11
|
+
export function resolveStaticVariables(template, inputs, workflowName) {
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
14
|
+
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
15
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
|
16
|
+
const trimmed = key.trim();
|
|
17
|
+
// {{input.topic}} → inputs['topic']
|
|
18
|
+
if (trimmed.startsWith('input.')) {
|
|
19
|
+
const inputKey = trimmed.slice(6);
|
|
20
|
+
return inputs[inputKey] ?? match;
|
|
21
|
+
}
|
|
22
|
+
// {{env.OWNER_NAME}} → config values
|
|
23
|
+
if (trimmed.startsWith('env.')) {
|
|
24
|
+
const envKey = trimmed.slice(4);
|
|
25
|
+
if (envKey === 'OWNER_NAME')
|
|
26
|
+
return OWNER_NAME || match;
|
|
27
|
+
if (envKey === 'ASSISTANT_NAME')
|
|
28
|
+
return ASSISTANT_NAME || match;
|
|
29
|
+
return match;
|
|
30
|
+
}
|
|
31
|
+
// {{workflow.name}} → workflow name
|
|
32
|
+
if (trimmed.startsWith('workflow.')) {
|
|
33
|
+
const field = trimmed.slice(9);
|
|
34
|
+
if (field === 'name')
|
|
35
|
+
return workflowName;
|
|
36
|
+
return match;
|
|
37
|
+
}
|
|
38
|
+
// {{date}}, {{time}}
|
|
39
|
+
if (trimmed === 'date')
|
|
40
|
+
return dateStr;
|
|
41
|
+
if (trimmed === 'time')
|
|
42
|
+
return timeStr;
|
|
43
|
+
// Leave unresolved (might be a step output reference)
|
|
44
|
+
return match;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve step output references after dependencies complete.
|
|
49
|
+
* Handles: {{steps.research.output}}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveStepOutputs(template, stepResults, truncateChars = 4000) {
|
|
52
|
+
return template.replace(/\{\{steps\.([^.]+)\.output\}\}/g, (_match, stepId) => {
|
|
53
|
+
const output = stepResults.get(stepId);
|
|
54
|
+
if (output === undefined)
|
|
55
|
+
return `[step "${stepId}" has no output]`;
|
|
56
|
+
if (output.length > truncateChars) {
|
|
57
|
+
return output.slice(0, truncateChars) + '\n...[truncated]';
|
|
58
|
+
}
|
|
59
|
+
return output;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=workflow-variables.js.map
|