yaml-flow 5.2.5 → 5.2.8

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 (84) hide show
  1. package/README.md +6 -6
  2. package/board-livecards-server-runtime.js +260 -35
  3. package/browser/board-livegraph-engine.js +57 -32
  4. package/browser/board-livegraph-engine.js.map +1 -1
  5. package/browser/card-compute.js +17 -17
  6. package/browser/live-cards.js +139 -12
  7. package/browser/live-cards.schema.json +14 -9
  8. package/dist/board-livegraph-runtime/index.cjs +57 -32
  9. package/dist/board-livegraph-runtime/index.cjs.map +1 -1
  10. package/dist/board-livegraph-runtime/index.d.cts +1 -1
  11. package/dist/board-livegraph-runtime/index.d.ts +1 -1
  12. package/dist/board-livegraph-runtime/index.js +57 -32
  13. package/dist/board-livegraph-runtime/index.js.map +1 -1
  14. package/dist/card-compute/index.cjs +96 -38
  15. package/dist/card-compute/index.cjs.map +1 -1
  16. package/dist/card-compute/index.d.cts +13 -8
  17. package/dist/card-compute/index.d.ts +13 -8
  18. package/dist/card-compute/index.js +96 -38
  19. package/dist/card-compute/index.js.map +1 -1
  20. package/dist/cli/board-live-cards-cli.cjs +7200 -201
  21. package/dist/cli/board-live-cards-cli.cjs.map +1 -1
  22. package/dist/cli/board-live-cards-cli.d.cts +6 -6
  23. package/dist/cli/board-live-cards-cli.d.ts +6 -6
  24. package/dist/cli/board-live-cards-cli.js +7199 -201
  25. package/dist/cli/board-live-cards-cli.js.map +1 -1
  26. package/dist/continuous-event-graph/index.cjs +55 -30
  27. package/dist/continuous-event-graph/index.cjs.map +1 -1
  28. package/dist/continuous-event-graph/index.d.cts +2 -2
  29. package/dist/continuous-event-graph/index.d.ts +2 -2
  30. package/dist/continuous-event-graph/index.js +55 -30
  31. package/dist/continuous-event-graph/index.js.map +1 -1
  32. package/dist/index.cjs +121 -53
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +121 -53
  37. package/dist/index.js.map +1 -1
  38. package/dist/{live-cards-bridge-CeNxiVcm.d.ts → live-cards-bridge-EQjytzI_.d.ts} +10 -5
  39. package/dist/{live-cards-bridge-z_rJCSbi.d.cts → live-cards-bridge-x5XREkXm.d.cts} +10 -5
  40. package/examples/browser/boards/portfolio-tracker/cards/holdings-table.json +1 -1
  41. package/examples/browser/boards/portfolio-tracker/cards/portfolio-form.json +1 -1
  42. package/examples/browser/boards/portfolio-tracker/cards/portfolio-risk-assessment.json +1 -1
  43. package/examples/browser/boards/portfolio-tracker/cards/portfolio-value.json +1 -1
  44. package/examples/browser/boards/portfolio-tracker/cards/price-fetch.json +2 -2
  45. package/examples/browser/boards/portfolio-tracker/cards/rebalancing-strategy.json +1 -1
  46. package/examples/browser/boards/portfolio-tracker/portfolio-tracker.js +10 -10
  47. package/examples/cli/step-machine-cli/portfolio-tracker/cards/holdings-table.json +1 -1
  48. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +1 -1
  49. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +1 -1
  50. package/examples/cli/step-machine-cli/portfolio-tracker/cards/price-fetch.json +2 -2
  51. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +1 -1
  52. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +1 -1
  53. package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +1 -1
  54. package/examples/example-board/agent-instructions-cardlayout.md +1 -1
  55. package/examples/example-board/agent-instructions.md +271 -45
  56. package/examples/example-board/cards/card-concentration.json +8 -5
  57. package/examples/example-board/cards/card-market-prices.json +14 -9
  58. package/examples/example-board/cards/card-my-identity.json +28 -0
  59. package/examples/example-board/cards/card-portfolio-value.json +1 -1
  60. package/examples/example-board/cards/card-portfolio.json +1 -1
  61. package/examples/example-board/cards/card-rebalance-impact.json +65 -0
  62. package/examples/example-board/cards/card-rebalance-sim.json +57 -0
  63. package/examples/example-board/demo-chat-handler.js +2 -1
  64. package/examples/example-board/demo-server-config.json +6 -1
  65. package/examples/example-board/demo-server.js +79 -8
  66. package/examples/example-board/demo-shell-browser.html +6 -6
  67. package/examples/example-board/demo-shell-with-server.html +4 -4
  68. package/examples/example-board/demo-task-executor.js +436 -246
  69. package/examples/example-board/scripts/copilot_wrapper.bat +157 -0
  70. package/examples/example-board/scripts/copilot_wrapper_helper.ps1 +190 -0
  71. package/examples/example-board/scripts/workiq_wrapper.mjs +66 -0
  72. package/examples/npm-libs/continuous-event-graph/live-cards-board.ts +5 -5
  73. package/examples/npm-libs/continuous-event-graph/soc-incident-board.ts +3 -3
  74. package/examples/npm-libs/event-graph/research-pipeline.ts +5 -5
  75. package/examples/npm-libs/graph-of-graphs/multi-stage-etl.ts +9 -9
  76. package/examples/step-machine-cli/portfolio-tracker/cards/holdings-table.json +1 -1
  77. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +1 -1
  78. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +1 -1
  79. package/examples/step-machine-cli/portfolio-tracker/cards/price-fetch.json +3 -3
  80. package/examples/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +1 -1
  81. package/examples/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +1 -1
  82. package/examples/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +1 -1
  83. package/package.json +2 -2
  84. package/schema/live-cards.schema.json +14 -9
@@ -3,35 +3,54 @@
3
3
  /**
4
4
  * demo-task-executor.js — Simple mock source executor for example-board.
5
5
  *
6
- * Protocol (invoked by board-live-cards-cli):
7
- * node demo-task-executor.js run-source-fetch --in <source.json> --out <result.json> [--err <error.txt>]
6
+ * Subcommands:
7
+ * run-source-fetch fetch data for one source entry
8
+ * describe-capabilities — print supported source kinds + schemas to stdout (JSON)
8
9
  *
9
- * Expected source definition (--in payload):
10
+ * CLI args:
11
+ * --in <source.json> Required. Path to a temp JSON file containing the source definition.
12
+ * --out <result.json> Required. Path where this executor must write its JSON result.
13
+ * --err <error.txt> Optional. Path where this executor writes an error message on failure.
14
+ * --extra <base64json> Optional. Base64-encoded JSON with board topology context
15
+ * (baked into .task-executor at board init time, passed blindly by the CLI).
16
+ *
17
+ * --in payload (source definition):
10
18
  * {
11
- * "bindTo": "...",
12
- * "outputFile": "...",
13
- * // custom fields authored on the source entry (e.g. mock, copilot, http, prompt_template, etc.)
14
- * "cwd": "<card directory>",
15
- * "boardDir": "<board runtime directory>",
16
- * "_requires": { }, // upstream token data (from card requires[])
17
- * "_sourcesData": { }, // already-fetched sources on this card
18
- * "_computed_values": { } // computed_values from the card's compute stage
19
+ * "bindTo": "token_name",
20
+ * "outputFile": "relative/path.json",
21
+ * "cwd": "<card directory>", // injected by CLI
22
+ * "boardDir":"<board runtime directory>", // injected by CLI
23
+ * "_projections": { "refKey": <resolvedValue> }, // named projections from card_data/requires,
24
+ * // declared in source_defs[].projections and resolved
25
+ * // by the engine before invoking the executor
26
+ * // ...plus any custom fields authored on the source entry (bindTo, outputFile, projections, etc.)
19
27
  * }
20
28
  *
21
- * Supported source kinds (based on custom fields):
29
+ * --extra (decoded):
30
+ * {
31
+ * "boardSetupRoot": "<abs path>", // board root (parent of runtime/, surface/, runtime-out/)
32
+ * "boardId": "<board id>", // e.g. "default"
33
+ * "boardRuntimeDir": "<relative>", // e.g. "runtime"
34
+ * "runtimeStatusDir": "<relative>", // e.g. "runtime-out"
35
+ * "cardsDir": "<relative>" // e.g. "surface/tmp-cards"
36
+ * }
37
+ *
38
+ * Supported source kinds (based on custom fields in --in):
22
39
  * - { mock: "key" } → look up key in MOCK_DB (hardcoded below)
23
40
  * - { copilot: { prompt_template, args? } } → call Copilot CLI with interpolated prompt
24
41
  * - { prompt_template: "..." } → shorthand copilot call (top-level template)
25
- * - { http: { url, method?, headers?, args? }, tickersFrom? } HTTP fetch (Node 18+ fetch)
26
- * - { chartApi: { url, headers? }, tickersFrom } → Yahoo Finance chart API, one request per ticker;
27
- * returns { quoteResponse: { result: [...] } } compatible with the quote API shape
42
+ * - { workiq: { query_template, args? } } call WorkIQ (M365 Copilot) with interpolated query
43
+ * - { "url": { url, method?, headers?, args?, cacheTimeout? }, tickersFrom? }
44
+ * single URL fetch via curl with {{key}} interpolation from _projections
45
+ * - { "url-list": { method?, headers?, cacheTimeout? } }
46
+ * → fan-out over _projections.url_list (string[]); returns array of responses.
47
+ * Build url_list in projections: e.g. `requires.holdings.ticker.('https://host/' & $ & '?q=1')`
48
+ * - { chartApi: { url, headers? }, tickersFrom } → removed; use url-list instead
49
+ * prefer url-list for new sources
28
50
  * A real executor can also handle: graphapi, teams, mail, incidentdb, script, etc.
29
51
  *
30
- * http / chartApi source notes:
31
- * - URL supports {{key}} interpolation (http) or {{ticker}} (chartApi)
32
- * - tickersFrom: "tokenName.fieldName" extracts tickers from a _requires array
33
- * - http and chartApi results are cached in os.tmpdir()/demo-executor-cache/ for 1 hour
34
- * so Yahoo Finance is not hammered on every card refresh during demos
52
+ * url / url-list notes:
53
+ * - Results cached in os.tmpdir()/demo-executor-cache/ per URL (default 1 hour, override via cacheTimeout)
35
54
  */
36
55
 
37
56
  import fs from 'node:fs';
@@ -62,7 +81,7 @@ const MOCK_DB = {
62
81
  };
63
82
 
64
83
  // ---------------------------------------------------------------------------
65
- // Simple 1-hour file cache for HTTP / chartApi results.
84
+ // Simple file cache for url / url-list results.
66
85
  // Stored in os.tmpdir()/demo-executor-cache/<hash>.json
67
86
  // ---------------------------------------------------------------------------
68
87
  const CACHE_DIR = path.join(os.tmpdir(), 'demo-executor-cache');
@@ -72,17 +91,32 @@ function cacheKey(str) {
72
91
  return crypto.createHash('sha1').update(str).digest('hex');
73
92
  }
74
93
 
75
- function readCache(key) {
94
+ function readCache(key, ttlMs = CACHE_TTL_MS) {
76
95
  const file = path.join(CACHE_DIR, `${key}.json`);
77
96
  try {
78
97
  const stat = fs.statSync(file);
79
- if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
98
+ if (Date.now() - stat.mtimeMs < ttlMs) {
80
99
  return JSON.parse(fs.readFileSync(file, 'utf-8'));
81
100
  }
82
101
  } catch {}
83
102
  return null;
84
103
  }
85
104
 
105
+ // Shared single-URL fetch helper used by both url and url-list.
106
+ // cacheTimeoutSec: override TTL in seconds (null → use CACHE_TTL_MS default).
107
+ function doFetchApi(url, method, headers, cacheTimeoutSec, errFile) {
108
+ const ttlMs = cacheTimeoutSec != null ? cacheTimeoutSec * 1000 : CACHE_TTL_MS;
109
+ const k = cacheKey(`url:${method}:${url}`);
110
+ const cached = readCache(k, ttlMs);
111
+ if (cached) {
112
+ console.warn(`[demo-task-executor] url: cache hit for ${url}`);
113
+ return cached;
114
+ }
115
+ const data = curlFetchJson(url, method, headers);
116
+ writeCache(k, data);
117
+ return data;
118
+ }
119
+
86
120
  function writeCache(key, value) {
87
121
  try {
88
122
  fs.mkdirSync(CACHE_DIR, { recursive: true });
@@ -123,52 +157,17 @@ function curlFetchJson(url, method, headers) {
123
157
  return JSON.parse(raw);
124
158
  }
125
159
 
126
- function stripCopilotFooter(rawText) {
127
- const lines = String(rawText ?? '').split(/\r?\n/);
128
-
129
- // Strip CLI-level tool-call telemetry lines emitted by the copilot binary when
130
- // --allow-all activates MCP tools. These are NOT model output — the prompt cannot
131
- // suppress them. They look like:
132
- // ● Web Search (MCP: github-mcp-server) · ...
133
- // └ {"type":"output_text",...}
134
- const filtered = lines.filter(line => {
135
- const t = line.trimStart();
136
- return !(
137
- /^[●•]\s+/.test(t) || // tool invocation lines
138
- /^└\s+/.test(t) // tool result lines
139
- );
140
- });
141
-
142
- // Remove trailing blank lines.
143
- while (filtered.length > 0 && filtered[filtered.length - 1].trim() === '') filtered.pop();
144
-
145
- // Remove the standard trailing Copilot metadata footer, if present.
146
- if (
147
- filtered.length >= 3 &&
148
- /^Changes\b/i.test(filtered[filtered.length - 3]) &&
149
- /^Requests\b/i.test(filtered[filtered.length - 2]) &&
150
- /^Tokens\b/i.test(filtered[filtered.length - 1])
151
- ) {
152
- filtered.splice(filtered.length - 3, 3);
153
- }
154
-
155
- while (filtered.length > 0 && filtered[filtered.length - 1].trim() === '') filtered.pop();
156
- return filtered.join('\n');
157
- }
158
-
159
160
  function resolveCopilotPrompt(sourceDef) {
160
161
  const cfg = sourceDef?.copilot && typeof sourceDef.copilot === 'object' ? sourceDef.copilot : {};
161
162
  const template = cfg.prompt_template ?? sourceDef.prompt_template;
162
163
  const args = cfg.args ?? cfg.prompt_args ?? sourceDef.prompt_args ?? sourceDef.args ?? {};
163
164
 
164
- // Merge all injected context for template interpolation.
165
- // _requires = upstream token data, _computed_values = card compute stage outputs,
166
- // _sourcesData = already-fetched sources on this card.
165
+ // Merge _projections into template interpolation context.
166
+ // _projections contains the named data projections declared in source_defs[].projections,
167
+ // evaluated by the engine from card_data/requires before invoking this executor.
167
168
  // Explicit args defined on the source take highest precedence.
168
169
  const interpolationContext = {
169
- ...sourceDef._requires,
170
- ...sourceDef._sourcesData,
171
- ...sourceDef._computed_values,
170
+ ...sourceDef._projections,
172
171
  ...args,
173
172
  };
174
173
 
@@ -176,93 +175,63 @@ function resolveCopilotPrompt(sourceDef) {
176
175
  return interpolatePrompt(template, interpolationContext);
177
176
  }
178
177
 
179
- function resolveCopilotExecutable() {
180
- const envBin = process.env.COPILOT_BIN;
181
- if (envBin && fs.existsSync(envBin)) {
182
- return envBin;
183
- }
184
-
185
- if (process.platform === 'win32') {
186
- try {
187
- const out = execFileSync('where.exe', ['copilot'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
188
- const candidates = out
189
- .split(/\r?\n/)
190
- .map((s) => s.trim())
191
- .filter(Boolean);
192
- const preferred = candidates.find((p) => /\.(cmd|exe|bat)$/i.test(p));
193
- if (preferred) return preferred;
194
- if (candidates[0]) return candidates[0];
195
- } catch {}
196
- } else {
197
- try {
198
- const out = execFileSync('which', ['copilot'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
199
- const first = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
200
- if (first) return first;
201
- } catch {}
178
+ /**
179
+ * Run a copilot prompt via copilot_wrapper.bat (Windows only).
180
+ *
181
+ * The wrapper handles:
182
+ * - Session management (--resume UUID for multi-turn continuity)
183
+ * - Noise/footer stripping (via copilot_wrapper_helper.ps1)
184
+ * - JSON mode extraction with optional result_shape key matching
185
+ * - Agentic retry: if the first response isn't valid JSON, the wrapper calls
186
+ * copilot again in the same session with a correction prompt, then re-extracts.
187
+ *
188
+ * @param {string} prompt - interpolated prompt string
189
+ * @param {object} sourceDef - source definition (may contain copilot.result_shape)
190
+ * @param {string} wrapperOutFile - path the wrapper writes its JSON output to
191
+ * @param {string} sessionDir - persistent dir for session UUID (enables --resume)
192
+ * @param {string} cwd - working directory for copilot (boardSetupRoot)
193
+ * @returns {unknown} parsed JSON result value
194
+ */
195
+ function runCopilotViaWrapper(prompt, sourceDef, wrapperOutFile, sessionDir, cwd) {
196
+ const wrapperPath = path.join(__dirname, 'scripts', 'copilot_wrapper.bat');
197
+
198
+ const promptFile = wrapperOutFile + '.prompt.txt';
199
+ fs.writeFileSync(promptFile, prompt, 'utf-8');
200
+
201
+ // Optional result_shape_file: top-level keys the response JSON must contain.
202
+ // Sourced from sourceDef.copilot.result_shape or sourceDef.result_shape.
203
+ let shapeFile = '';
204
+ const shape = sourceDef?.copilot?.result_shape ?? sourceDef?.result_shape;
205
+ if (shape && typeof shape === 'object') {
206
+ shapeFile = wrapperOutFile + '.shape.json';
207
+ fs.writeFileSync(shapeFile, JSON.stringify(shape), 'utf-8');
202
208
  }
203
209
 
204
- return 'copilot';
205
- }
206
-
207
- function runCopilotPrompt(prompt) {
208
- const copilotBin = resolveCopilotExecutable();
209
- const copilotArgs = ['--allow-all'];
210
+ fs.mkdirSync(sessionDir, { recursive: true });
210
211
 
211
212
  try {
212
- // Prefer stdin prompt delivery to avoid shell/path quoting issues.
213
- return execFileSync(copilotBin, copilotArgs, {
214
- input: String(prompt),
213
+ execFileSync('cmd.exe', [
214
+ '/d', '/c',
215
+ wrapperPath,
216
+ wrapperOutFile, // OUTPUT_FILE
217
+ sessionDir, // SESSION_DIR
218
+ cwd || process.cwd(), // WORKING_DIR
219
+ '@' + promptFile, // REQUEST_OR_FILE (@ prefix = file path)
220
+ 'json', // RESULT_TYPE — wrapper extracts JSON + retries
221
+ sourceDef.bindTo || 'executor', // AGENT_NAME (for log file naming)
222
+ '', // MODEL (empty = wrapper default)
223
+ shapeFile, // RESULT_SHAPE_FILE (empty = accept any JSON)
224
+ ], {
215
225
  encoding: 'utf-8',
216
- stdio: ['pipe', 'pipe', 'pipe'],
226
+ stdio: ['ignore', 'pipe', 'pipe'],
217
227
  maxBuffer: 10 * 1024 * 1024,
218
228
  });
219
- } catch (directErr) {
220
- // Fallback for Git Bash / Windows wrapper path quoting issues.
221
- if (process.platform === 'win32') {
222
- const isCmdShim = /\.(bat|cmd)$/i.test(copilotBin);
223
-
224
- if (isCmdShim) {
225
- try {
226
- return execFileSync(copilotBin, copilotArgs, {
227
- input: String(prompt),
228
- encoding: 'utf-8',
229
- stdio: ['pipe', 'pipe', 'pipe'],
230
- maxBuffer: 10 * 1024 * 1024,
231
- shell: true,
232
- });
233
- } catch {}
234
- }
235
-
236
- try {
237
- // Final fallback: resolve through cmd PATH lookup, still piping prompt on stdin.
238
- return execFileSync('cmd.exe', ['/d', '/c', 'copilot --allow-all'], {
239
- input: String(prompt),
240
- encoding: 'utf-8',
241
- stdio: ['pipe', 'pipe', 'pipe'],
242
- maxBuffer: 10 * 1024 * 1024,
243
- });
244
- } catch (cmdErr) {
245
- const stderrDirect = directErr && typeof directErr === 'object' && 'stderr' in directErr
246
- ? String(directErr.stderr || '')
247
- : '';
248
- const stderrCmd = cmdErr && typeof cmdErr === 'object' && 'stderr' in cmdErr
249
- ? String(cmdErr.stderr || '')
250
- : '';
251
- const msg = [stderrDirect.trim(), stderrCmd.trim(), String(cmdErr && cmdErr.message || cmdErr)]
252
- .filter(Boolean)
253
- .join(' | ');
254
- throw new Error(msg || 'copilot invocation failed');
255
- }
256
- }
257
-
258
- const stderrDirect = directErr && typeof directErr === 'object' && 'stderr' in directErr
259
- ? String(directErr.stderr || '')
260
- : '';
261
- const msg = [stderrDirect.trim(), String(directErr && directErr.message || directErr)]
262
- .filter(Boolean)
263
- .join(' | ');
264
- throw new Error(msg || 'copilot invocation failed');
229
+ } finally {
230
+ try { fs.unlinkSync(promptFile); } catch {}
231
+ if (shapeFile) { try { fs.unlinkSync(shapeFile); } catch {} }
265
232
  }
233
+
234
+ return JSON.parse(fs.readFileSync(wrapperOutFile, 'utf-8').replace(/^\uFEFF/, ''));
266
235
  }
267
236
 
268
237
  function fail(msg, errFile) {
@@ -279,9 +248,17 @@ function runSourceFetchSubcommand(argv) {
279
248
  const inIdx = argv.indexOf('--in');
280
249
  const outIdx = argv.indexOf('--out');
281
250
  const errIdx = argv.indexOf('--err');
251
+ const extraIdx = argv.indexOf('--extra');
282
252
  const inFile = inIdx !== -1 ? argv[inIdx + 1] : undefined;
283
253
  const outFile = outIdx !== -1 ? argv[outIdx + 1] : undefined;
284
254
  const errFile = errIdx !== -1 ? argv[errIdx + 1] : undefined;
255
+ const extraB64 = extraIdx !== -1 ? argv[extraIdx + 1] : undefined;
256
+
257
+ let extra = {};
258
+ if (extraB64) {
259
+ try { extra = JSON.parse(Buffer.from(extraB64, 'base64').toString('utf-8')); }
260
+ catch { console.warn('[demo-task-executor] bad --extra base64, ignoring'); }
261
+ }
285
262
 
286
263
  if (!inFile || !outFile) {
287
264
  fail('Usage: run-source-fetch --in <source.json> --out <result.json> [--err <error.txt>]', errFile);
@@ -300,119 +277,67 @@ function runSourceFetchSubcommand(argv) {
300
277
 
301
278
  let resultValue;
302
279
 
303
- if (sourceDef.chartApi) {
280
+ if (sourceDef['url']) {
304
281
  // ---------------------------------------------------------------------------
305
- // chartApi source kind Yahoo Finance v8/finance/chart (free, per-ticker)
306
- // Uses curl (synchronous subprocess) to avoid Node.js libuv handle issues.
282
+ // urlsingle URL fetch via curl
283
+ // {{key}} interpolation applied to url from _projections and optional args.
284
+ // cacheTimeout: seconds to cache the response (default: CACHE_TTL_MS / 1000).
307
285
  // ---------------------------------------------------------------------------
308
- const chartCfg = sourceDef.chartApi;
309
- const headers = { ...(chartCfg.headers || {}) };
286
+ const cfg = sourceDef['url'];
287
+ const method = (cfg.method || 'GET').toUpperCase();
288
+ const headers = { ...(cfg.headers || {}) };
289
+ const cacheTimeoutSec = cfg.cacheTimeout != null ? Number(cfg.cacheTimeout) : null;
310
290
 
311
- // Extract tickers array from _requires via tickersFrom
312
- let tickers = [];
291
+ const fetchArgs = { ...(cfg.args || {}) };
313
292
  if (sourceDef.tickersFrom) {
314
293
  const dotIdx = sourceDef.tickersFrom.indexOf('.');
315
294
  if (dotIdx > 0) {
316
- const tokenName = sourceDef.tickersFrom.slice(0, dotIdx);
295
+ const refKey = sourceDef.tickersFrom.slice(0, dotIdx);
317
296
  const fieldName = sourceDef.tickersFrom.slice(dotIdx + 1);
318
- const arr = sourceDef._requires?.[tokenName];
297
+ const arr = sourceDef._projections?.[refKey];
319
298
  if (Array.isArray(arr)) {
320
- tickers = arr.map(h => h[fieldName]).filter(Boolean);
299
+ fetchArgs.tickers = arr.map(h => h[fieldName]).filter(Boolean).join(',');
321
300
  }
322
301
  }
323
302
  }
324
-
325
- if (tickers.length === 0) {
326
- console.warn('[demo-task-executor] chartApi: tickersFrom resolved to empty list — falling back to mock');
327
- } else {
328
- const chartCacheKey = cacheKey('chartApi:' + tickers.sort().join(',') + chartCfg.url);
329
- const cached = readCache(chartCacheKey);
330
- if (cached) {
331
- console.warn(`[demo-task-executor] chartApi: cache hit for [${tickers.join(', ')}]`);
332
- resultValue = cached;
333
- } else {
334
- try {
335
- const results = [];
336
- for (const ticker of tickers) {
337
- const url = interpolatePrompt(chartCfg.url, { ticker });
338
- const data = curlFetchJson(url, 'GET', headers);
339
- const meta = data?.chart?.result?.[0]?.meta;
340
- if (!meta) throw new Error(`No chart meta for ${ticker}`);
341
- // Map to quote-compatible shape; compute change from chartPreviousClose
342
- const price = meta.regularMarketPrice ?? 0;
343
- const prevClose = meta.chartPreviousClose ?? price;
344
- const change = price - prevClose;
345
- const changePct = prevClose !== 0 ? (change / prevClose) * 100 : 0;
346
- results.push({
347
- symbol: meta.symbol ?? ticker,
348
- shortName: meta.shortName ?? meta.longName ?? ticker,
349
- regularMarketPrice: price,
350
- regularMarketChange: change,
351
- regularMarketChangePercent: changePct,
352
- });
353
- }
354
- resultValue = { quoteResponse: { result: results, error: null } };
355
- writeCache(chartCacheKey, resultValue);
356
- } catch (chartErr) {
357
- fail(`chartApi fetch failed: ${chartErr.message}`, errFile);
358
- }
359
- }
303
+ if (sourceDef.tickersFrom && !fetchArgs.tickers) {
304
+ fail('url: tickersFrom resolved to empty list — skipping fetch', errFile);
360
305
  }
361
-
362
- if (resultValue === undefined) {
363
- fail('chartApi: no tickers resolved — cannot fetch', errFile);
306
+ const urlContext = { ...(sourceDef._projections || {}), ...fetchArgs };
307
+ const url = interpolatePrompt(cfg.url, urlContext);
308
+ try {
309
+ resultValue = doFetchApi(url, method, headers, cacheTimeoutSec, errFile);
310
+ } catch (err) {
311
+ fail(`url failed: ${err.message}`, errFile);
364
312
  }
365
313
 
366
- } else if (sourceDef.http) {
314
+ } else if (sourceDef['url-list']) {
367
315
  // ---------------------------------------------------------------------------
368
- // HTTP source kind uses curl (synchronous subprocess)
316
+ // url-list fan-out over a URL list, calling url logic per URL.
317
+ // url_list must be a string[] pre-resolved in _projections.url_list.
318
+ // cacheTimeout: seconds to cache each individual response.
369
319
  // ---------------------------------------------------------------------------
370
- const httpCfg = sourceDef.http;
320
+ const cfg = sourceDef['url-list'];
321
+ const method = (cfg.method || 'GET').toUpperCase();
322
+ const headers = { ...(cfg.headers || {}) };
323
+ const cacheTimeoutSec = cfg.cacheTimeout != null ? Number(cfg.cacheTimeout) : null;
371
324
 
372
- // Build tickers string if tickersFrom is specified on the source
373
- // e.g. tickersFrom: "holdings.ticker" → joins _requires.holdings[*].ticker with ','
374
- const httpArgs = { ...(httpCfg.args || {}) };
375
- if (sourceDef.tickersFrom) {
376
- const dotIdx = sourceDef.tickersFrom.indexOf('.');
377
- if (dotIdx > 0) {
378
- const tokenName = sourceDef.tickersFrom.slice(0, dotIdx);
379
- const fieldName = sourceDef.tickersFrom.slice(dotIdx + 1);
380
- const arr = sourceDef._requires?.[tokenName];
381
- if (Array.isArray(arr)) {
382
- httpArgs.tickers = arr.map(h => h[fieldName]).filter(Boolean).join(',');
383
- }
384
- }
325
+ const urlList = Array.isArray(sourceDef._projections?.url_list)
326
+ ? sourceDef._projections.url_list : null;
327
+
328
+ if (!urlList || urlList.length === 0) {
329
+ fail('url-list: _projections.url_list must be a non-empty string array', errFile);
385
330
  }
386
331
 
387
- // Interpolate URL template with all available context
388
- const urlContext = {
389
- ...(sourceDef._requires || {}),
390
- ...(sourceDef._computed_values || {}),
391
- ...httpArgs,
392
- };
393
- const url = interpolatePrompt(httpCfg.url, urlContext);
394
- const method = (httpCfg.method || 'GET').toUpperCase();
395
- const headers = { ...(httpCfg.headers || {}) };
396
-
397
- // Skip fetch entirely if tickers ended up empty (guard against empty ?symbols=)
398
- const httpFetchSkipped = sourceDef.tickersFrom && !httpArgs.tickers;
399
-
400
- const httpCacheKey = cacheKey(`http:${method}:${url}`);
401
- const httpCached = readCache(httpCacheKey);
402
- if (httpCached && !httpFetchSkipped) {
403
- console.warn(`[demo-task-executor] http: cache hit for ${url}`);
404
- resultValue = httpCached;
405
- } else {
332
+ const results = [];
333
+ for (const u of urlList) {
406
334
  try {
407
- if (httpFetchSkipped) {
408
- throw new Error('tickersFrom resolved to empty list — skipping fetch');
409
- }
410
- resultValue = curlFetchJson(url, method, headers);
411
- writeCache(httpCacheKey, resultValue);
412
- } catch (httpErr) {
413
- fail(`HTTP fetch failed: ${httpErr.message}`, errFile);
335
+ results.push(doFetchApi(u, method, headers, cacheTimeoutSec, errFile));
336
+ } catch (err) {
337
+ fail(`url-list fetch failed for ${u}: ${err.message}`, errFile);
414
338
  }
415
339
  }
340
+ resultValue = results;
416
341
 
417
342
  } else if (sourceDef.copilot || sourceDef.prompt_template) {
418
343
  const prompt = resolveCopilotPrompt(sourceDef);
@@ -420,22 +345,88 @@ function runSourceFetchSubcommand(argv) {
420
345
  fail('Source definition missing copilot.prompt_template (or prompt_template)', errFile);
421
346
  }
422
347
 
423
- let rawOutput = '';
424
- try {
425
- rawOutput = runCopilotPrompt(prompt);
426
- } catch (err) {
427
- const msg = String(err && err.message || err);
428
- fail(`copilot invocation failed: ${msg}`, errFile);
348
+ // Use boardSetupRoot (from --extra) as copilot working directory
349
+ const copilotCwd = extra.boardSetupRoot || undefined;
350
+
351
+ // On Windows, delegate entirely to copilot_wrapper.bat which handles:
352
+ // - session management (--resume UUID for multi-turn continuity)
353
+ // - noise/footer stripping, JSON extraction, agentic retry on bad shape
354
+ // On non-Windows, fall back to a basic direct invocation (no retry).
355
+ const wrapperPath = path.join(__dirname, 'scripts', 'copilot_wrapper.bat');
356
+ const useWrapper = process.platform === 'win32' && fs.existsSync(wrapperPath);
357
+
358
+ if (useWrapper) {
359
+ // Session dir is stable across refreshes so --resume continues the conversation.
360
+ const sessionDir = path.join(
361
+ extra.boardSetupRoot || os.tmpdir(),
362
+ 'copilot-sessions',
363
+ String(sourceDef.bindTo || 'default').replace(/[^a-zA-Z0-9_-]/g, '_'),
364
+ );
365
+ const wrapperOutFile = outFile + '.wrapper-out.json';
366
+ try {
367
+ resultValue = runCopilotViaWrapper(prompt, sourceDef, wrapperOutFile, sessionDir, copilotCwd);
368
+ } catch (err) {
369
+ fail(`copilot invocation failed: ${String(err && err.message || err)}`, errFile);
370
+ } finally {
371
+ try { fs.unlinkSync(wrapperOutFile); } catch {}
372
+ }
373
+ } else {
374
+ // Non-Windows fallback: call copilot directly via cmd.exe and do basic JSON extraction.
375
+ let rawOutput = '';
376
+ try {
377
+ rawOutput = execFileSync('cmd.exe', ['/d', '/c', 'copilot --allow-all'], {
378
+ input: String(prompt),
379
+ encoding: 'utf-8',
380
+ stdio: ['pipe', 'pipe', 'pipe'],
381
+ maxBuffer: 10 * 1024 * 1024,
382
+ ...(copilotCwd ? { cwd: copilotCwd } : {}),
383
+ });
384
+ } catch (err) {
385
+ fail(`copilot invocation failed: ${String(err && err.message || err)}`, errFile);
386
+ }
387
+ // Basic JSON extraction: find first { or [ in output
388
+ const firstBrace = rawOutput.indexOf('{');
389
+ const firstBracket = rawOutput.indexOf('[');
390
+ const jsonStart = (firstBrace === -1) ? firstBracket
391
+ : (firstBracket === -1) ? firstBrace
392
+ : Math.min(firstBrace, firstBracket);
393
+ if (jsonStart !== -1) {
394
+ try {
395
+ const parsed = JSON.parse(rawOutput.slice(jsonStart));
396
+ resultValue = (parsed && typeof parsed === 'object') ? parsed : rawOutput;
397
+ } catch {
398
+ resultValue = rawOutput;
399
+ }
400
+ } else {
401
+ resultValue = rawOutput;
402
+ }
403
+ }
404
+ } else if (sourceDef.workiq) {
405
+ const cfg = typeof sourceDef.workiq === 'object' ? sourceDef.workiq : {};
406
+ if (!cfg.query_template || typeof cfg.query_template !== 'string') {
407
+ fail('Source definition missing workiq.query_template', errFile);
429
408
  }
409
+ const interpolationContext = { ...sourceDef._projections, ...(cfg.args ?? {}) };
410
+ const query = interpolatePrompt(cfg.query_template, interpolationContext);
430
411
 
431
- const cleaned = stripCopilotFooter(rawOutput);
432
- // If the response is a JSON object/array, parse it so downstream compute
433
- // can reference fields directly (e.g. fetched_sources.analysis.mix).
412
+ const wrapperPath = path.join(__dirname, 'scripts', 'workiq_wrapper.mjs');
413
+ if (!fs.existsSync(wrapperPath)) {
414
+ fail('workiq source kind requires workiq_wrapper.js in scripts/', errFile);
415
+ }
434
416
  try {
435
- const parsed = JSON.parse(cleaned);
436
- resultValue = (parsed && typeof parsed === 'object') ? parsed : cleaned;
437
- } catch {
438
- resultValue = cleaned;
417
+ execFileSync(process.execPath, [wrapperPath, outFile], {
418
+ encoding: 'utf-8',
419
+ stdio: ['inherit', 'pipe', 'pipe'],
420
+ maxBuffer: 10 * 1024 * 1024,
421
+ env: {
422
+ ...process.env,
423
+ WORKIQ_QUERY: query,
424
+ ...(extra.serverUrl ? { WORKIQ_SERVER_URL: extra.serverUrl } : {}),
425
+ },
426
+ });
427
+ return; // wrapper wrote directly to outFile
428
+ } catch (err) {
429
+ fail(`workiq invocation failed: ${String(err && err.message || err)}`, errFile);
439
430
  }
440
431
  } else if (sourceDef.mock) {
441
432
  // MOCK_DB lookup — data hardcoded at the top of this file
@@ -444,7 +435,7 @@ function runSourceFetchSubcommand(argv) {
444
435
  fail(`Key "${sourceDef.mock}" not found in MOCK_DB`, errFile);
445
436
  }
446
437
  } else {
447
- fail('Source definition has no recognised kind (copilot, http, chartApi, mock)', errFile);
438
+ fail('Source definition has no recognised kind (url, url-list, copilot, workiq, mock)', errFile);
448
439
  }
449
440
 
450
441
  // Write result to --out as JSON payload, same contract as current mock mode.
@@ -456,12 +447,211 @@ function runSourceFetchSubcommand(argv) {
456
447
 
457
448
  }
458
449
 
450
+ // ---------------------------------------------------------------------------
451
+ // validate-source-def — structural validation of a source definition
452
+ // ---------------------------------------------------------------------------
453
+ function validateSourceDefSubcommand(argv) {
454
+ const inIdx = argv.indexOf('--in');
455
+ const inFile = inIdx !== -1 ? argv[inIdx + 1] : undefined;
456
+
457
+ if (!inFile) {
458
+ console.error('[demo-task-executor] Usage: validate-source-def --in <source.json>');
459
+ process.exit(1);
460
+ }
461
+
462
+ if (!fs.existsSync(inFile)) {
463
+ console.log(JSON.stringify({ ok: false, errors: [`Input file not found: ${inFile}`] }));
464
+ process.exit(1);
465
+ }
466
+
467
+ let sourceDef;
468
+ try {
469
+ sourceDef = readJson(inFile);
470
+ } catch (err) {
471
+ console.log(JSON.stringify({ ok: false, errors: [`Cannot parse source file: ${err && err.message || err}`] }));
472
+ process.exit(1);
473
+ }
474
+
475
+ const errors = [];
476
+
477
+ // Determine source kind and validate required fields
478
+ const hasUrl = !!sourceDef['url'];
479
+ const hasUrlList = !!sourceDef['url-list'];
480
+ const hasCopilot = !!sourceDef.copilot;
481
+ const hasPromptTemplate = typeof sourceDef.prompt_template === 'string';
482
+ const hasWorkiq = !!sourceDef.workiq;
483
+ const hasMock = sourceDef.mock !== undefined;
484
+
485
+ const kindCount = [hasUrl, hasUrlList, hasCopilot || hasPromptTemplate, hasWorkiq, hasMock].filter(Boolean).length;
486
+
487
+ if (kindCount === 0) {
488
+ errors.push('No recognised source kind (url, url-list, copilot, workiq, mock). Add one of these fields.');
489
+ } else if (kindCount > 1) {
490
+ const kinds = [];
491
+ if (hasUrl) kinds.push('url');
492
+ if (hasUrlList) kinds.push('url-list');
493
+ if (hasCopilot || hasPromptTemplate) kinds.push('copilot');
494
+ if (hasWorkiq) kinds.push('workiq');
495
+ if (hasMock) kinds.push('mock');
496
+ errors.push(`Multiple source kinds specified: [${kinds.join(', ')}]. Use exactly one.`);
497
+ }
498
+
499
+ if (hasUrl) {
500
+ if (typeof sourceDef['url'] !== 'object') {
501
+ errors.push('url must be an object.');
502
+ } else if (!sourceDef['url'].url || typeof sourceDef['url'].url !== 'string') {
503
+ errors.push('url.url is required and must be a string.');
504
+ }
505
+ }
506
+
507
+ if (hasUrlList) {
508
+ if (typeof sourceDef['url-list'] !== 'object') {
509
+ errors.push('url-list must be an object.');
510
+ }
511
+ // url_list is supplied via _projections at runtime — no static validation needed.
512
+ }
513
+
514
+ if (hasCopilot) {
515
+ if (typeof sourceDef.copilot !== 'object') {
516
+ errors.push('copilot must be an object.');
517
+ } else {
518
+ if (!sourceDef.copilot.prompt_template && !hasPromptTemplate) {
519
+ errors.push('copilot.prompt_template is required (or use top-level prompt_template).');
520
+ }
521
+ }
522
+ }
523
+
524
+ if (hasWorkiq) {
525
+ if (typeof sourceDef.workiq !== 'object') {
526
+ errors.push('workiq must be an object.');
527
+ } else if (!sourceDef.workiq.query_template || typeof sourceDef.workiq.query_template !== 'string') {
528
+ errors.push('workiq.query_template is required and must be a string.');
529
+ }
530
+ }
531
+
532
+ if (hasMock) {
533
+ if (typeof sourceDef.mock !== 'string') {
534
+ errors.push('mock must be a string key.');
535
+ }
536
+ }
537
+
538
+ const result = { ok: errors.length === 0, errors };
539
+ console.log(JSON.stringify(result));
540
+ process.exit(errors.length === 0 ? 0 : 1);
541
+ }
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // describe-capabilities — introspection metadata for this executor
545
+ // ---------------------------------------------------------------------------
546
+ const CAPABILITIES = {
547
+ version: '1.0',
548
+ executor: 'demo-task-executor',
549
+ subcommands: ['run-source-fetch', 'describe-capabilities', 'validate-source-def'],
550
+ sourceKinds: {
551
+ mock: {
552
+ description: 'Look up a key in a hardcoded MOCK_DB dictionary.',
553
+ inputSchema: {
554
+ mock: { type: 'string', required: true, description: 'Key in MOCK_DB (e.g. "quotes").' },
555
+ },
556
+ outputShape: 'Arbitrary JSON — depends on the mock key.',
557
+ example: {
558
+ input: { mock: 'quotes' },
559
+ output: { quoteResponse: { result: [{ symbol: 'AAPL', regularMarketPrice: 198.15 }], error: null } },
560
+ },
561
+ },
562
+ copilot: {
563
+ description: 'Invoke GitHub Copilot CLI with an interpolated prompt template.',
564
+ inputSchema: {
565
+ copilot: {
566
+ type: 'object', required: false,
567
+ description: 'Object with prompt_template (string) and optional args (object).',
568
+ properties: {
569
+ prompt_template: { type: 'string', required: true, description: 'Prompt with {{key}} placeholders.' },
570
+ args: { type: 'object', required: false, description: 'Extra interpolation args (highest precedence).' },
571
+ },
572
+ },
573
+ prompt_template: { type: 'string', required: false, description: 'Shorthand — top-level prompt template (alternative to copilot.prompt_template).' },
574
+ },
575
+ outputShape: 'string | object — raw Copilot text, or parsed JSON if the response is valid JSON.',
576
+ },
577
+ workiq: {
578
+ description: 'Query WorkIQ (Microsoft 365 Copilot) with an interpolated query template. Returns raw text response.',
579
+ inputSchema: {
580
+ workiq: {
581
+ type: 'object', required: true,
582
+ properties: {
583
+ query_template: { type: 'string', required: true, description: 'Query with {{key}} placeholders interpolated from _projections and args.' },
584
+ args: { type: 'object', required: false, description: 'Extra interpolation args (highest precedence).' },
585
+ },
586
+ },
587
+ },
588
+ outputShape: 'string — raw M365 Copilot response text.',
589
+ note: 'Requires workiq CLI installed and Azure CLI logged in (az login).',
590
+ },
591
+ 'url': {
592
+ description: 'Single URL fetch via curl with {{key}} interpolation from _projections. Supports cacheTimeout.',
593
+ inputSchema: {
594
+ 'url': {
595
+ type: 'object', required: true,
596
+ properties: {
597
+ url: { type: 'string', required: true, description: 'URL template with {{key}} placeholders.' },
598
+ method: { type: 'string', required: false, description: 'HTTP method (default: GET).' },
599
+ headers: { type: 'object', required: false, description: 'Request headers.' },
600
+ args: { type: 'object', required: false, description: 'Extra interpolation args (highest precedence).' },
601
+ cacheTimeout: { type: 'number', required: false, description: 'Cache TTL in seconds (default: 3600).' },
602
+ },
603
+ },
604
+ tickersFrom: { type: 'string', required: false, description: '"refKey.fieldName" — join tickers from _projections into {{tickers}}.' },
605
+ },
606
+ outputShape: 'Arbitrary JSON from the fetched URL.',
607
+ },
608
+ 'url-list': {
609
+ description: 'Fan-out over a pre-resolved URL list — calls url logic per URL and returns an array of responses. url_list must be a string[] in _projections.url_list (built via projections JSONata).',
610
+ inputSchema: {
611
+ 'url-list': {
612
+ type: 'object', required: true,
613
+ properties: {
614
+ method: { type: 'string', required: false, description: 'HTTP method (default: GET).' },
615
+ headers: { type: 'object', required: false, description: 'Request headers.' },
616
+ cacheTimeout: { type: 'number', required: false, description: 'Cache TTL per URL in seconds (default: 3600).' },
617
+ },
618
+ },
619
+ },
620
+ outputShape: 'Array of raw JSON responses, one per URL in _projections.url_list.',
621
+ urlListNote: 'Declare `"projections": { "url_list": "<JSONata producing string[]>" }` on the source def. Example: `requires.holdings.ticker.(\'https://api.example.com/\' & $ & \'?q=1\')`',
622
+ },
623
+ },
624
+ extraSchema: {
625
+ description: 'Board topology context passed via --extra (base64-encoded JSON, baked at init).',
626
+ properties: {
627
+ boardSetupRoot: { type: 'string', description: 'Absolute path to board root.' },
628
+ boardId: { type: 'string', description: 'Board identifier.' },
629
+ boardRuntimeDir: { type: 'string', description: 'Relative path to runtime dir.' },
630
+ runtimeStatusDir: { type: 'string', description: 'Relative path to runtime-out dir.' },
631
+ cardsDir: { type: 'string', description: 'Relative path to cards dir.' },
632
+ serverUrl: { type: 'string', description: 'Base URL of the hosting server (e.g. http://127.0.0.1:7799). Used by source kinds that call server-side proxy endpoints.' },
633
+ },
634
+ },
635
+ };
636
+
637
+ function describeCapabilities() {
638
+ console.log(JSON.stringify(CAPABILITIES, null, 2));
639
+ }
640
+
459
641
  async function main() {
460
642
  const sub = process.argv[2];
461
643
  if (sub === 'run-source-fetch') {
462
644
  runSourceFetchSubcommand(process.argv.slice(3));
463
645
  return;
464
646
  }
647
+ if (sub === 'describe-capabilities') {
648
+ describeCapabilities();
649
+ return;
650
+ }
651
+ if (sub === 'validate-source-def') {
652
+ validateSourceDefSubcommand(process.argv.slice(3));
653
+ return;
654
+ }
465
655
 
466
656
  console.warn(`[demo-task-executor] Unknown subcommand: ${sub}`);
467
657
  process.exit(0);