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.
Files changed (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. 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