lazyclaw 6.3.0 → 6.4.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 (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -0,0 +1,101 @@
1
+ // workflow/declarative.mjs — parse + compile + run a declarative workflow.
2
+ //
3
+ // Workflows used to be hand-written .mjs that passed arbitrary node functions
4
+ // to the executor (workflow/executor.mjs). This compiles a DATA definition
5
+ // (JSON; a node-type library backs it) into the same WorkflowNode shape, so a
6
+ // workflow can be authored, stored, and run from config — and, next, served by
7
+ // the daemon over HTTP. Orchestration (ordering, timeout, retry, cleanup,
8
+ // cancellation) is still the existing executor; this layer only builds nodes.
9
+ //
10
+ // Definition shape:
11
+ // { name?, input?, nodes: [ { id, type, config?, deps?, timeoutMs?, retry? } ] }
12
+ // Data flows by {{ref}}: a node's config can reference any prior node's output
13
+ // by id (e.g. "{{fetchUser.name}}"), resolved from a shared bag at run time.
14
+
15
+ import { runSequential } from './executor.mjs';
16
+ import { NODE_TYPES, resolveRefs } from './nodes.mjs';
17
+
18
+ export class WorkflowError extends Error {
19
+ constructor(message, code) {
20
+ super(message);
21
+ this.name = 'WorkflowError';
22
+ this.code = code || 'WF_ERR';
23
+ }
24
+ }
25
+
26
+ // Validate the static shape (ids present + unique, nodes is an array). Type
27
+ // existence is checked at compile time, where the caps-provided types are known.
28
+ export function validateWorkflow(def) {
29
+ if (!def || typeof def !== 'object' || Array.isArray(def)) {
30
+ throw new WorkflowError('workflow must be an object', 'WF_SHAPE');
31
+ }
32
+ if (!Array.isArray(def.nodes) || def.nodes.length === 0) {
33
+ throw new WorkflowError('workflow needs a non-empty nodes[] array', 'WF_NODES');
34
+ }
35
+ const ids = new Set();
36
+ for (const n of def.nodes) {
37
+ if (!n || typeof n !== 'object') throw new WorkflowError('each node must be an object', 'WF_NODE');
38
+ if (typeof n.id !== 'string' || !n.id.trim()) throw new WorkflowError('each node needs a non-empty string id', 'WF_NODE_ID');
39
+ if (ids.has(n.id)) throw new WorkflowError(`duplicate node id: ${n.id}`, 'WF_DUP_ID');
40
+ ids.add(n.id);
41
+ if (typeof n.type !== 'string' || !n.type.trim()) throw new WorkflowError(`node "${n.id}" needs a type`, 'WF_NODE_TYPE');
42
+ }
43
+ return def;
44
+ }
45
+
46
+ export function parseWorkflow(text) {
47
+ let def;
48
+ try { def = JSON.parse(text); }
49
+ catch (e) { throw new WorkflowError(`invalid workflow JSON: ${e.message}`, 'WF_PARSE'); }
50
+ return validateWorkflow(def);
51
+ }
52
+
53
+ // Parse a workflow def from a minimal YAML subset (no new dependency — see
54
+ // workflow/yaml_min.mjs for the supported grammar), then validate it the same
55
+ // way as JSON. For authoring ergonomics; JSON remains the canonical format.
56
+ export async function parseWorkflowYaml(text) {
57
+ const { parseYamlMin } = await import('./yaml_min.mjs');
58
+ let def;
59
+ try { def = parseYamlMin(text); }
60
+ catch (e) { throw new WorkflowError(`invalid workflow YAML: ${e.message}`, 'WF_PARSE'); }
61
+ return validateWorkflow(def);
62
+ }
63
+
64
+ // Compile a (validated) definition into executor nodes. caps.nodeTypes adds
65
+ // side-effecting node types (http/shell/llm/channel) the caller chose to grant;
66
+ // the built-in safe types are always present. Returns { nodes, bag } where bag
67
+ // accumulates each node's output by id for {{ref}} resolution.
68
+ export function compileWorkflow(def, caps = {}) {
69
+ validateWorkflow(def);
70
+ const registry = { ...NODE_TYPES, ...(caps.nodeTypes && typeof caps.nodeTypes === 'object' ? caps.nodeTypes : {}) };
71
+ for (const spec of def.nodes) {
72
+ if (typeof registry[spec.type] !== 'function') {
73
+ throw new WorkflowError(`unknown node type "${spec.type}" (node "${spec.id}") — known: ${Object.keys(registry).join(', ')}`, 'WF_UNKNOWN_TYPE');
74
+ }
75
+ }
76
+ const bag = {};
77
+ const nodes = def.nodes.map((spec) => ({
78
+ id: spec.id,
79
+ deps: Array.isArray(spec.deps) ? spec.deps : undefined,
80
+ timeoutMs: spec.timeoutMs,
81
+ retry: spec.retry,
82
+ execute: async (input, ctx = {}) => {
83
+ const handler = registry[spec.type];
84
+ const cfg = resolveRefs(spec.config || {}, bag);
85
+ const out = await handler(cfg, { input, bag, caps, signal: ctx.signal });
86
+ bag[spec.id] = out;
87
+ return out;
88
+ },
89
+ }));
90
+ return { nodes, bag };
91
+ }
92
+
93
+ // Compile + run a declarative workflow sequentially. Returns the executor's
94
+ // { success, results, session } (session[id] = each node's output).
95
+ export async function runWorkflow(def, opts = {}) {
96
+ const { nodes, bag } = compileWorkflow(def, opts.caps || {});
97
+ // Seed the run input so a node can reference it as {{input}} (e.g. the text of
98
+ // an inbound Slack message that triggered the workflow).
99
+ if (opts.input !== undefined) bag.input = opts.input;
100
+ return runSequential(nodes, opts.input ?? null, { signal: opts.signal });
101
+ }
@@ -0,0 +1,71 @@
1
+ // workflow/named.mjs — stored, named declarative workflows + run-by-name.
2
+ //
3
+ // A named workflow lives in cfg.workflows[<name>] (alongside cfg.cron /
4
+ // cfg.channels), so it can be triggered by name from the CLI, a cron job, or an
5
+ // inbound Slack message — the Hermes-style automation spine. Entry shape:
6
+ // cfg.workflows[name] = { def: { nodes:[...] }, channel?: 'slack:#x',
7
+ // schedule?: '0 9 * * *', replyNode?: 'reply' }
8
+ // The def is the same declarative format runDeclarativeRequest already runs;
9
+ // this module only adds storage + a name guard + reply-text selection.
10
+
11
+ import { runDeclarativeRequest } from './run_request.mjs';
12
+
13
+ const NAME_RE = /^[A-Za-z0-9_.-]+$/;
14
+ export function validWorkflowName(name) {
15
+ return typeof name === 'string' && name.length > 0 && NAME_RE.test(name);
16
+ }
17
+
18
+ export function getNamedWorkflow(cfg, name) {
19
+ const entry = cfg && cfg.workflows && typeof cfg.workflows === 'object' ? cfg.workflows[name] : undefined;
20
+ return entry && typeof entry === 'object' && entry.def ? entry : null;
21
+ }
22
+
23
+ export function listNamedWorkflows(cfg) {
24
+ const ws = (cfg && cfg.workflows && typeof cfg.workflows === 'object') ? cfg.workflows : {};
25
+ return Object.keys(ws).sort().map((name) => ({
26
+ name,
27
+ channel: ws[name]?.channel || null,
28
+ schedule: ws[name]?.schedule || null,
29
+ nodes: Array.isArray(ws[name]?.def?.nodes) ? ws[name].def.nodes.length : 0,
30
+ }));
31
+ }
32
+
33
+ // Find the named workflow bound to an inbound channel (entry.channel, e.g.
34
+ // "slack:#ops" or "slack:C123"), or null. Used by POST /inbound to trigger a
35
+ // workflow on a Slack message. Matches the channel id/name after stripping the
36
+ // "slack:"/"#" prefixes so a binding written either way still resolves.
37
+ export function workflowForChannel(cfg, channel) {
38
+ if (!channel) return null;
39
+ const ws = (cfg && cfg.workflows && typeof cfg.workflows === 'object') ? cfg.workflows : {};
40
+ const c = String(channel).replace(/^#/, '');
41
+ for (const name of Object.keys(ws)) {
42
+ const bound = ws[name] && ws[name].channel;
43
+ if (!bound || !ws[name].def) continue;
44
+ const target = String(bound).replace(/^slack:/, '').replace(/^#/, '');
45
+ if (target === c) return { name, ...ws[name] };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ // Run a stored workflow by name. opts is forwarded to runDeclarativeRequest
51
+ // (providerLookup for the llm node, fetchImpl, input, sessionId, signal).
52
+ export async function runNamedWorkflow(name, cfg, opts = {}) {
53
+ const entry = getNamedWorkflow(cfg, name);
54
+ if (!entry) throw new Error(`no workflow named "${name}"`);
55
+ return runDeclarativeRequest(entry.def, cfg, opts);
56
+ }
57
+
58
+ // The text to surface back (Slack reply / CLI output): the explicitly-named
59
+ // replyNode if set, else the LAST node's output. A workflow ending in an
60
+ // http/json node returns an object, so authors should name a reply node.
61
+ export function namedReplyText(result, entry = {}) {
62
+ const session = (result && result.session) || {};
63
+ if (entry.replyNode && entry.replyNode in session) {
64
+ const v = session[entry.replyNode];
65
+ return typeof v === 'string' ? v : JSON.stringify(v);
66
+ }
67
+ const keys = Object.keys(session);
68
+ if (!keys.length) return '';
69
+ const v = session[keys[keys.length - 1]];
70
+ return typeof v === 'string' ? v : JSON.stringify(v);
71
+ }
@@ -0,0 +1,50 @@
1
+ // workflow/named_cron.mjs — schedule a named declarative workflow via cron.
2
+ //
3
+ // Mirrors goals_cron.mjs: install an OS cron job (launchd / crontab) whose
4
+ // command is `lazyclaw workflow run <name>`, so the OS scheduler fires the
5
+ // stored workflow on its schedule (no resident daemon loop). The job runs the
6
+ // def and posts its reply to the bound channel (commands/workflow_named.mjs).
7
+ // LAZYCLAW_SKIP_CRON_INSTALL writes the config but skips the backend install,
8
+ // so the wiring is unit-testable with no real launchd/crontab.
9
+ //
10
+ // Note: launchd/crontab do NOT capture the shell env, so a scheduled run that
11
+ // posts to Slack needs SLACK_BOT_TOKEN available to the cron environment
12
+ // (export it in the launch context) — same requirement as the goal-tick fan-out.
13
+
14
+ import * as cronMod from '../cron.mjs';
15
+ import { readConfig as defaultRead, writeConfig as defaultWrite } from '../lib/config.mjs';
16
+
17
+ export function attachWorkflowCron(name, schedule, deps = {}) {
18
+ const cron = deps.cron || cronMod;
19
+ const readConfig = deps.readConfig || defaultRead;
20
+ const writeConfig = deps.writeConfig || defaultWrite;
21
+ cron.parseCronSpec(schedule); // validate before touching state
22
+ const cfg = readConfig();
23
+ const jobName = `wf-${name}`;
24
+ const cmd = ['lazyclaw', 'workflow', 'run', name];
25
+ cron.upsertJob(cfg, jobName, schedule, cmd);
26
+ writeConfig(cfg);
27
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return { jobName, skipped: true };
28
+ const backend = cron.pickBackend();
29
+ if (backend === 'launchd') cron.installLaunchdJob(jobName, schedule, cmd);
30
+ else cron.installCrontabJob(jobName, schedule, cmd);
31
+ return { jobName, skipped: false };
32
+ }
33
+
34
+ export function detachWorkflowCron(name, deps = {}) {
35
+ const cron = deps.cron || cronMod;
36
+ const readConfig = deps.readConfig || defaultRead;
37
+ const writeConfig = deps.writeConfig || defaultWrite;
38
+ const cfg = readConfig();
39
+ const jobName = `wf-${name}`;
40
+ if (!cfg.cron || !cfg.cron[jobName]) return false;
41
+ cron.removeJob(cfg, jobName);
42
+ writeConfig(cfg);
43
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return true;
44
+ const backend = cron.pickBackend();
45
+ try {
46
+ if (backend === 'launchd') cron.uninstallLaunchdJob(jobName);
47
+ else cron.uninstallCrontabJob(jobName);
48
+ } catch { /* best-effort — cron sync recovers */ }
49
+ return true;
50
+ }
@@ -0,0 +1,67 @@
1
+ // workflow/nodes.mjs — the built-in node-type library for declarative workflows.
2
+ //
3
+ // A declarative workflow is data: a list of { id, type, config } specs. The
4
+ // compiler (workflow/declarative.mjs) turns each spec into a WorkflowNode the
5
+ // executor understands, resolving {{ref}} placeholders in `config` against a
6
+ // shared bag of prior node outputs first. Each node TYPE is a pure handler:
7
+ // handler(config, ctx) => output (sync or async)
8
+ // ctx = { input, bag, caps, signal }
9
+ // Built-in types here are SAFE (no I/O). Side-effecting types (http, shell,
10
+ // llm, channel-send) are injected via caps.nodeTypes so the daemon decides
11
+ // what a workflow is allowed to do — capability injection, not ambient power.
12
+
13
+ // Resolve a dotted ref ("a.b.c") against the bag of prior outputs.
14
+ export function getRef(bag, pathStr) {
15
+ const parts = String(pathStr).split('.');
16
+ let v = bag;
17
+ for (const p of parts) {
18
+ if (v == null) return undefined;
19
+ v = v[p.trim()];
20
+ }
21
+ return v;
22
+ }
23
+
24
+ const WHOLE_REF = /^\{\{\s*([\w.$-]+)\s*\}\}$/;
25
+ const INLINE_REF = /\{\{\s*([\w.$-]+)\s*\}\}/g;
26
+
27
+ // Deep-resolve {{ref}} placeholders in a config value against the bag. A string
28
+ // that is EXACTLY "{{ref}}" becomes the raw referenced value (may be a non-
29
+ // string); inline refs inside a longer string interpolate to text.
30
+ export function resolveRefs(value, bag) {
31
+ if (typeof value === 'string') {
32
+ const whole = value.match(WHOLE_REF);
33
+ if (whole) return getRef(bag, whole[1]);
34
+ return value.replace(INLINE_REF, (_, ref) => {
35
+ const v = getRef(bag, ref);
36
+ if (v == null) return '';
37
+ return typeof v === 'object' ? JSON.stringify(v) : String(v);
38
+ });
39
+ }
40
+ if (Array.isArray(value)) return value.map((x) => resolveRefs(x, bag));
41
+ if (value && typeof value === 'object') {
42
+ const out = {};
43
+ for (const [k, v] of Object.entries(value)) out[k] = resolveRefs(v, bag);
44
+ return out;
45
+ }
46
+ return value;
47
+ }
48
+
49
+ export const NODE_TYPES = {
50
+ // Emit a constant (or a ref'd value). The simplest source node.
51
+ set: (cfg) => (cfg && 'value' in cfg ? cfg.value : null),
52
+ // A string built from prior outputs. cfg.text already had its {{refs}}
53
+ // interpolated by the compiler, so this just returns the resolved string.
54
+ template: (cfg) => String(cfg?.text ?? ''),
55
+ // Parse a JSON string ({ parse }) into a value, or stringify ({ stringify }).
56
+ // Lets a downstream node {{ref}} into an http body's fields, etc.
57
+ json: (cfg) => {
58
+ if (cfg && 'parse' in cfg) { try { return JSON.parse(String(cfg.parse)); } catch { return null; } }
59
+ if (cfg && 'stringify' in cfg) return JSON.stringify(cfg.stringify);
60
+ return null;
61
+ },
62
+ // Shallow-merge a list of objects into one (later entries win).
63
+ merge: (cfg) => Object.assign({}, ...((Array.isArray(cfg?.values) ? cfg.values : []).filter((v) => v && typeof v === 'object'))),
64
+ // { value } unless it is null/undefined/'' — then { fallback }. Useful after
65
+ // an optional http/llm step that may have produced nothing.
66
+ default: (cfg) => { const v = cfg?.value; return (v === null || v === undefined || v === '') ? (cfg?.fallback ?? null) : v; },
67
+ };
@@ -0,0 +1,74 @@
1
+ // workflow/run_request.mjs — bridge a declarative workflow definition + the
2
+ // daemon/CLI config into a safe run. Caps are derived from config, NOT from the
3
+ // workflow: http (SSRF-guarded) is always granted; llm is bound to the
4
+ // configured provider when one resolves. Shell and other powerful types are
5
+ // NOT granted here — a posted workflow can never spawn a process. Returns a
6
+ // plain serializable result; throws WorkflowError on a malformed definition.
7
+
8
+ import { validateWorkflow, runWorkflow, compileWorkflow } from './declarative.mjs';
9
+ import { buildCaps } from './builtin_caps.mjs';
10
+ import { runPersistent, loadState } from './persistent.mjs';
11
+
12
+ // Caps from config (never the workflow): http always, llm bound to the
13
+ // configured provider, nothing else. Shared by the stateless + persistent paths.
14
+ function _capsFromConfig(cfg, opts) {
15
+ const provider = typeof opts.providerLookup === 'function' ? opts.providerLookup(cfg.provider) : null;
16
+ const grants = { http: { fetchImpl: opts.fetchImpl || globalThis.fetch } };
17
+ if (provider && typeof provider.sendMessage === 'function') {
18
+ grants.llm = { provider, apiKey: cfg['api-key'], model: cfg.model };
19
+ }
20
+ return buildCaps(grants);
21
+ }
22
+
23
+ export async function runDeclarativeRequest(def, cfg = {}, opts = {}) {
24
+ validateWorkflow(def); // throws WorkflowError (caught by the caller → 400)
25
+ // sessionId opts in to the persisted/resumable engine (state under opts.dir).
26
+ if (opts.sessionId) return runDeclarativePersistent(def, cfg, opts);
27
+ const caps = _capsFromConfig(cfg, opts);
28
+ const r = await runWorkflow(def, { caps, input: opts.input, signal: opts.signal });
29
+ return {
30
+ ok: r.success,
31
+ success: r.success,
32
+ session: r.session || {},
33
+ results: (r.results || []).map((x) => ({ id: x.id, status: x.status, duration: x.duration })),
34
+ ...(r.error ? { error: r.error.message, failedAt: r.failedAt } : {}),
35
+ };
36
+ }
37
+
38
+ // Persisted/resumable run: state is keyed by sessionId under opts.dir. A second
39
+ // call with the same sessionId resumes — already-succeeded nodes are skipped.
40
+ // CRITICAL: the declarative {{ref}} bag is closed over the compiled nodes, so on
41
+ // resume the skipped nodes never repopulate it. We pre-seed the bag from the
42
+ // prior state's success outputs, or a downstream {{ref}} to a skipped node would
43
+ // resolve to undefined.
44
+ export async function runDeclarativePersistent(def, cfg = {}, opts = {}) {
45
+ validateWorkflow(def);
46
+ const caps = _capsFromConfig(cfg, opts);
47
+ const { nodes, bag } = compileWorkflow(def, caps);
48
+ if (opts.input !== undefined) bag.input = opts.input;
49
+ const prior = loadState(opts.sessionId, opts.dir);
50
+ if (prior && prior.nodes) {
51
+ for (const id of (prior.order || [])) {
52
+ const ns = prior.nodes[id];
53
+ if (ns && ns.status === 'success' && 'output' in ns) bag[id] = ns.output;
54
+ }
55
+ }
56
+ const r = await runPersistent(nodes, { sessionId: opts.sessionId, dir: opts.dir, signal: opts.signal });
57
+ // Reconstruct the {nodeId: output} session view from the persisted state so
58
+ // the response shape matches the stateless path.
59
+ const session = {};
60
+ if (r.state && r.state.nodes) {
61
+ for (const id of (r.state.order || [])) {
62
+ const ns = r.state.nodes[id];
63
+ if (ns && 'output' in ns) session[id] = ns.output;
64
+ }
65
+ }
66
+ return {
67
+ ok: r.success,
68
+ success: r.success,
69
+ session,
70
+ sessionId: opts.sessionId,
71
+ executedNodes: r.executedNodes || [],
72
+ ...(r.error ? { error: r.error, failedAt: r.failedAt } : {}),
73
+ };
74
+ }
@@ -0,0 +1,97 @@
1
+ // workflow/yaml_min.mjs — a MINIMAL, dependency-free block-YAML parser for
2
+ // declarative workflow definitions. NOT a full YAML implementation (no anchors,
3
+ // aliases, tags, multi-document, or complex keys) — it covers exactly what a
4
+ // workflow def needs and errors clearly on anything else rather than guessing:
5
+ // - block mappings key: value
6
+ // - block sequences - item
7
+ // - sequences of mappings - id: a\n type: b (the `nodes:` shape)
8
+ // - scalars strings / numbers / true / false / null
9
+ // - quoted strings "x" / 'x'
10
+ // - inline JSON flow [a, b] / {k: v} (delegated to JSON.parse)
11
+ // - block scalars | (literal) / > (folded) (good for llm prompts)
12
+ // Tabs are rejected (YAML forbids them for indentation). The result is a plain
13
+ // JS value passed to validateWorkflow, so a bad def still fails the same checks.
14
+
15
+ export class YamlMinError extends Error {
16
+ constructor(message) { super(message); this.name = 'YamlMinError'; this.code = 'YAML_MIN'; }
17
+ }
18
+
19
+ function scalar(text) {
20
+ const s = text.trim();
21
+ if (s === '') return null;
22
+ // A flow indicator ([ or {) must be valid inline JSON — error rather than
23
+ // silently treating an unclosed/garbled flow value as a string.
24
+ if (s[0] === '[' || s[0] === '{') {
25
+ try { return JSON.parse(s); } catch (e) { throw new YamlMinError(`invalid inline JSON: ${s}`); }
26
+ }
27
+ if (s === 'true') return true;
28
+ if (s === 'false') return false;
29
+ if (s === 'null' || s === '~') return null;
30
+ if (/^-?\d+(?:\.\d+)?$/.test(s)) return Number(s);
31
+ if (s[0] === '"' && s[s.length - 1] === '"') { try { return JSON.parse(s); } catch { return s.slice(1, -1); } }
32
+ if (s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1);
33
+ return s;
34
+ }
35
+
36
+ export function parseYamlMin(text) {
37
+ const src = String(text).replace(/\r\n/g, '\n').split('\n');
38
+ // Keep raw lines but track logical (non-blank/comment) ones with their index.
39
+ const lines = [];
40
+ for (const raw of src) {
41
+ if (/^\s*#/.test(raw) || raw.trim() === '') continue;
42
+ if (/^\s*\t/.test(raw)) throw new YamlMinError('tabs are not allowed for indentation');
43
+ lines.push(raw);
44
+ }
45
+ let i = 0;
46
+ const indentOf = (l) => l.length - l.trimStart().length;
47
+ const curIndent = () => (i < lines.length ? indentOf(lines[i]) : -1);
48
+
49
+ function parseBlock(indent) {
50
+ const t = lines[i].trim();
51
+ return (t === '-' || t.startsWith('- ')) ? parseSeq(indent) : parseMap(indent);
52
+ }
53
+
54
+ function parseMap(indent) {
55
+ const obj = {};
56
+ while (i < lines.length && indentOf(lines[i]) === indent) {
57
+ const line = lines[i].trim();
58
+ const ci = line.indexOf(':');
59
+ if (ci < 0) throw new YamlMinError(`expected "key: value", got: ${line}`);
60
+ const key = line.slice(0, ci).trim();
61
+ const rest = line.slice(ci + 1).trim();
62
+ i++;
63
+ if (rest === '|' || rest === '>') { obj[key] = blockScalar(indent, rest); }
64
+ else if (rest === '') {
65
+ obj[key] = (i < lines.length && curIndent() > indent) ? parseBlock(curIndent()) : null;
66
+ } else { obj[key] = scalar(rest); }
67
+ }
68
+ return obj;
69
+ }
70
+
71
+ function parseSeq(indent) {
72
+ const arr = [];
73
+ while (i < lines.length && indentOf(lines[i]) === indent && (lines[i].trim() === '-' || lines[i].trim().startsWith('- '))) {
74
+ const rest = lines[i].trim().slice(1).trim();
75
+ if (rest === '') { i++; arr.push(curIndent() > indent ? parseBlock(curIndent()) : null); }
76
+ else if (/^[^[{"'][^:]*:(\s|$)/.test(rest)) {
77
+ // "- key: val ..." → a mapping item; re-emit the first key at indent+2
78
+ // so parseMap reads it together with this item's deeper-indented keys.
79
+ lines[i] = ' '.repeat(indent + 2) + rest;
80
+ arr.push(parseMap(indent + 2));
81
+ } else { i++; arr.push(scalar(rest)); }
82
+ }
83
+ return arr;
84
+ }
85
+
86
+ function blockScalar(parentIndent, style) {
87
+ const out = [];
88
+ while (i < lines.length && indentOf(lines[i]) > parentIndent) {
89
+ out.push(lines[i].slice(parentIndent + 2));
90
+ i++;
91
+ }
92
+ return out.join(style === '>' ? ' ' : '\n');
93
+ }
94
+
95
+ if (i >= lines.length) return {};
96
+ return parseBlock(curIndent());
97
+ }