Compare commits

...

116 Commits

Author SHA1 Message Date
xiaoju 762ecec872 feat(cli-uwf): thread read shows Content + new step-details command
- thread read: add ### Content section (last assistant message) before ### Output
- Remove --detail flag (replaced by step-details command)
- New: uwf thread step-details <step-hash> — full detail dump as yaml

Closes #357
2026-05-19 06:44:18 +00:00
xiaoju c0ac4ade09 fix(uwf-agent-hermes): consume outputFormatInstruction in prompt
buildHermesPrompt was ignoring ctx.outputFormatInstruction — the
deliverable format and scope constraint were injected into context
but never passed to the agent.

Now prepends it before systemPrompt (deliverable-first principle).

Refs #355
2026-05-19 06:23:13 +00:00
xiaomo a991393053 Merge pull request 'feat(uwf-agent-kit): frontmatter fast path + prompt injection — #355' (#356) from feat/355-uwf-frontmatter into main 2026-05-19 06:21:35 +00:00
xiaoju 892ccab8d5 feat(uwf-agent-kit): frontmatter fast path + prompt injection
Port RFC #351 frontmatter markdown to uwf-* path:
- tryFrontmatterFastPath(): parse → validate → JSON Schema check via json-cas
- Happy path skips LLM extract, fallback to existing extract()
- buildOutputFormatInstruction(): generates deliverable format from JSON Schema
- Injected into agent context before execution
- Scope reminder: 'Focus exclusively on YOUR role's deliverable'
- 14 new tests (vitest)

Closes #355
2026-05-19 06:20:15 +00:00
xiaomo 70c83c65b0 Merge pull request 'feat(workflow-util-agent): prompt restructure + scope focus — RFC #351 Phase 3' (#354) from feat/351-phase3-prompt-focus into main 2026-05-19 05:57:37 +00:00
xiaoju 8a7e756fe3 feat(workflow-util-agent): prompt restructure + scope focus — Phase 3
- buildOutputFormatInstruction(schema): auto-generates frontmatter
  format guide from Zod schema, injected at top of system prompt
- Adapter prepends deliverable format before role's systemPrompt
- buildThreadInput reordered: Task → Steps → Parent → Tools
- Scope reminder: 'Focus exclusively on YOUR role's deliverable'
- 8 tests for buildOutputFormatInstruction

Refs #351
2026-05-19 05:56:27 +00:00
xiaomo 4a4ddba9f6 Merge pull request 'feat(workflow-util-agent): two-layer frontmatter safeguard — RFC #351 Phase 2' (#353) from feat/351-phase2-adapter-frontmatter into main 2026-05-19 05:47:46 +00:00
xiaoju d5f47d1a18 feat(workflow-util-agent): two-layer frontmatter safeguard in adapter
Phase 2 of RFC #351 — adapter tries frontmatter first (zero LLM cost),
falls back to runtime.extract() when frontmatter is missing/invalid.

- tryFrontmatterMeta(): parse → validate → schema.safeParse
- Happy path stores body (no frontmatter) in CAS
- Fallback stores full raw in CAS + LLM extract
- 5 tests covering both paths

Refs #351
2026-05-19 05:46:36 +00:00
xiaoju 37c35560e9 docs: fix parseMinimalYaml JSDoc (nit from #352 review)
Refs #351
2026-05-19 05:41:18 +00:00
xiaomo f174b96028 Merge pull request 'feat(workflow-util): frontmatter markdown parser — RFC #351 Phase 1' (#352) from feat/351-frontmatter-markdown-phase1 into main 2026-05-19 04:56:58 +00:00
xiaomo 432400ee20 Merge pull request 'feat: uwf thread read — human-readable markdown with pagination' (#350) from feat/349-thread-read into main 2026-05-19 03:45:02 +00:00
xiaoju dacebe1841 feat(thread-read): show role system prompt in each step
Each step block now includes a '### Prompt' section showing the
role's systemPrompt from the workflow definition.

Refs #349
2026-05-19 03:23:50 +00:00
xiaoju c42125946d feat(thread-read): expand detail recursively via cas_ref
--detail now uses expandDeep to recursively resolve all cas_ref
fields in the detail merkle tree, showing full turn content
instead of raw hashes.

Refs #349
2026-05-19 03:19:40 +00:00
xiaoju 4c9ce72395 feat: uwf thread read — human-readable markdown with pagination
- Outputs markdown directly (not JSON/YAML)
- --quota <chars>: character budget, loads steps backward until exceeded (default 4000)
- --before <step-hash>: load steps before this hash (exclusive), omits start
- --start: force include start section even with --before
- --detail: expand detail CAS node content for each step
- Skip hint with uwf thread read command for pagination
- Reuses walkChain/collectOrderedSteps/expandOutput

Closes #349
2026-05-19 03:15:38 +00:00
xiaomo 8b43f7993b Merge pull request 'fix: parse session_id from stderr — hermes --quiet writes it there' (#348) from fix/348-session-id-stderr into main 2026-05-18 17:10:29 +00:00
xiaoju cf9e2cd3d6 fix: parse session_id from stderr (hermes --quiet writes it there)
hermes --quiet outputs session_id to stderr and AI response to stdout.
The agent was only parsing stdout, so session_id was never found and
detail always fell back to raw output.

Now checks stderr first, then stdout as fallback.
2026-05-18 17:05:54 +00:00
xiaomo 7a99c1a9d6 Merge pull request 'fix: hermes agent empty detail — parse session_id from any line' (#347) from fix/342-parse-session-id into main 2026-05-18 16:58:24 +00:00
xiaoju 546237db85 fix: parseSessionIdFromStdout scans all lines, not just last
--quiet mode outputs session_id on the first line, not the last.
The old code only checked the last non-empty line and broke immediately
if it didn't match, causing session detail to always be empty.

Fixes the empty detail bug when hermes agent runs in quiet mode.
2026-05-18 16:57:24 +00:00
xiaomo 1ed7e32067 Merge pull request 'simplify: thread fork only takes step-hash' (#346) from fix/342-fork-simplify into main 2026-05-18 16:43:33 +00:00
xiaoju bd5e5a435b simplify: thread fork only takes step-hash
Remove thread-id argument — CAS node is self-contained, no need to
specify which thread it belongs to. Just verify the hash is a valid
StartNode or StepNode.

Refs #342
2026-05-18 16:38:55 +00:00
xiaomo 67e689ff1a Merge pull request 'feat: thread steps + thread fork' (#345) from feat/342-thread-steps-fork into main 2026-05-18 16:34:55 +00:00
xiaoju 06eb2dff3b feat: add thread steps and thread fork commands
- uwf thread steps <thread-id>: walk CAS chain, list all steps chronologically
- uwf thread fork <thread-id> <step-hash>: create new thread from history point
- New types: StartEntry, StepEntry, ThreadStepsOutput, ThreadForkOutput
- Supports both active and archived threads

Refs #342
2026-05-18 16:30:12 +00:00
xiaomo a2bd3126c8 Merge pull request 'refactor: AgentContext extends ModeratorContext, remove redundant fields' (#341) from refactor/simplify-agent-context into main 2026-05-18 16:17:16 +00:00
xiaoju 710d42d6b9 refactor(agent-kit): base AgentContext on ModeratorContext
AgentContext now extends ModeratorContext (start + steps) with threadId, role, store, and expanded workflow. Hermes and mock-agent read prompt/steps/systemPrompt from the new shape.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 16:14:13 +00:00
xiaomo 072d900fcb Merge pull request 'refactor: pass store via AgentContext, eliminate duplicate store instances' (#340) from refactor/pass-store-via-context into main 2026-05-18 16:05:38 +00:00
xiaoju cfebd07124 refactor(agent-kit): pass CAS store through AgentContext
Expose the store created during context build on AgentContext so agents
reuse the same in-memory cache instead of opening a second store.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 16:04:15 +00:00
xiaoju f2be6fc057 Merge pull request 'feat: hermes merkle detail — session turns as CAS tree (Phase 2 of #337)' (#339) from feat/337-agent-detail-merkle into main 2026-05-18 15:58:01 +00:00
xiaoju d392563549 feat(uwf-hermes): Phase 2 merkle detail from Hermes session JSON
Parse session_id from Hermes stdout, store hermes-turn leaves and
hermes-detail root in CAS with cas_ref turns; fall back to raw stdout
when the session file is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:56:50 +00:00
xiaoju 2af8196451 Merge pull request 'feat: agent-kit interface change — agents own their detail (Phase 1 of #337)' (#338) from feat/337-agent-detail-merkle into main 2026-05-18 15:52:56 +00:00
xiaoju ad74768630 feat(uwf-agent): Phase 1 agent returns output and detailHash
- Change AgentRunFn to return { output, detailHash } instead of raw string

- Remove agent-kit detail CAS write; agents store their own detail nodes

- Hermes stores raw output as typed hermes-raw-output CAS node

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:29:48 +00:00
xiaoju a38ca7e8db chore: upgrade json-cas deps to ^0.3.0 2026-05-18 15:27:01 +00:00
xiaomo 3d97968887 Merge pull request 'feat: add uwf cas reindex command' (#334) from feat/cas-reindex into main 2026-05-18 14:25:38 +00:00
xiaoju ade6227ffe feat: add uwf cas reindex command
Rebuilds _index from all .bin nodes. Use after upgrading to json-cas 0.2.0
on an existing CAS directory.
2026-05-18 14:24:23 +00:00
xiaomo 13789e2c66 Merge pull request 'refactor: use listByType for schema list, upgrade json-cas to 0.2.0' (#333) from refactor/use-list-by-type into main 2026-05-18 14:18:16 +00:00
xiaoju 6758adc1d5 refactor: use listByType for schema list, upgrade json-cas to 0.2.0
Replace O(n) full CAS scan with O(1) type-index lookup.

Refs #328
2026-05-18 14:16:15 +00:00
xiaomo 7c12015855 Merge pull request 'refactor: merge cas get/cat into get, default hides timestamp' (#332) from refactor/merge-cas-get-cat into main 2026-05-18 14:03:50 +00:00
xiaoju 0f6859678c refactor: merge cas get/cat into get, default hides timestamp
- Remove `cas cat` command
- `cas get` now returns {type, payload} by default
- Add `--timestamp` flag to include timestamp

Refs #328
2026-05-18 14:03:10 +00:00
xiaomo 84798510b0 Merge pull request 'refactor: remove table output format, keep json and yaml only' (#331) from refactor/remove-table-format into main 2026-05-18 13:59:12 +00:00
xiaoju 6eace09826 refactor: remove table output format, keep json and yaml only
Table format adds complexity without readability gain over yaml.

Refs #328
2026-05-18 13:57:46 +00:00
xiaomo cb39a6693a Merge pull request 'fix: table format without header row' (#330) from fix/328-table-vertical into main 2026-05-18 13:48:17 +00:00
xiaoju 36d120b745 fix: table format — horizontal for arrays, vertical for objects
Arrays: horizontal table with HEADER row
Objects: vertical KEY/VALUE table
Primitives: fall back to yaml

小橘 🍊(NEKO Team)
2026-05-18 13:43:50 +00:00
jiayi 86dd37b0c8 Merge pull request 'feat: add office-agent document workflow (template + writer + differ)' (#327) from user/jiayiyan/feat_office-agent-document-template-v2 into main
Reviewed-on: #327
2026-05-18 13:42:03 +00:00
xiaomo bb0f2ca678 Merge pull request 'feat: --format json/yaml/table for all non-interactive commands' (#329) from feat/328-format-option into main 2026-05-18 13:40:19 +00:00
xiaomo ec0bc672f6 Merge pull request 'feat: --format json/yaml/table for all non-interactive commands' (#329) from feat/328-format-option into main 2026-05-18 13:36:02 +00:00
jiayiyan f08ba6914c chore: remove .DS_Store and add to .gitignore 2026-05-18 21:35:40 +08:00
xiaoju 7dd6ab5328 feat: --format json/yaml/table for all non-interactive commands
Add program-level --format option (default: json) inherited by all
subcommands. json output unchanged, yaml via yaml package, table
renders aligned columns for arrays, falls back to yaml for objects.

Closes #328

小橘 🍊(NEKO Team)
2026-05-18 13:33:41 +00:00
jiayiyan f6dd4d59a1 docs: add office-agent document template spec and implementation plan 2026-05-18 21:26:11 +08:00
jiayiyan d8cdc8ab88 feat(agent): add workflow-agent-office runner with generate/edit and tests 2026-05-18 21:26:11 +08:00
jiayiyan 20ddc5d7aa docs(architecture): add workflow-agent-office, workflow-agent-docx-diff, workflow-template-document
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan 2846311f8d feat(agent): add workflow-agent-docx-diff with docx-diff AdapterFn
Implements createDocxDiffAgent (AdapterFn), packageDescriptor, and exports in index.ts; 9 tests pass (runner 6 + agent 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan ed0043b8ac feat(agent): scaffold workflow-agent-docx-diff package
Add package.json, tsconfig.json, and placeholder src/index.ts for
@uncaged/workflow-agent-docx-diff; append reference in root tsconfig.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan bee3911f3f feat(agent): add workflow-agent-office with generate/edit AdapterFn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
jiayiyan 4285b8b180 feat(agent): scaffold workflow-agent-office package
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:26:11 +08:00
xiaomo 7c955fa749 Merge pull request 'fix: uwf cas — JSON output + meta-schema in schema list' (#326) from fix/319-cas-json-output into main 2026-05-18 13:25:16 +00:00
jiayiyan f0b7be79fb feat(template): add workflow-template-document with writer/differ roles and moderator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:24:58 +08:00
jiayiyan d4f05adeba chore(template): scaffold workflow-template-document package
Add package.json, tsconfig.json, and placeholder src/index.ts for the
@uncaged/workflow-template-document package; register it in root tsconfig.json references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:24:58 +08:00
xiaoju c4c9f96117 fix: uwf cas commands output JSON, include meta-schema in schema list
All cas subcommands now output JSON via writeJson(), consistent with
other uwf commands. schema list includes meta-schema. Removed --json
flag and --format tree (tree is human-only, not machine-friendly).

Refs #319

小橘 🍊(NEKO Team)
2026-05-18 13:24:19 +00:00
xiaomo 633d5aeafe Merge pull request 'refactor: outputSchema only accepts inline JSON Schema' (#325) from fix/319-validate-schema-only-inline into main 2026-05-18 13:18:17 +00:00
xiaoju 17103c1ee1 refactor: outputSchema only accepts inline JSON Schema
- Remove CAS ref string support from workflow YAML outputSchema
- Simplify validate.ts: no string check for outputSchema
- Auto-set title from role name (workflow.role format)

Refs #319

小橘 🍊(NEKO Team)
2026-05-18 13:17:29 +00:00
xiaomo c8a39be9bd Merge pull request 'fix: remove cas list, add schema titles' (#324) from fix/319-schema-titles into main 2026-05-18 13:07:15 +00:00
xiaoju b304f65876 feat: auto-set outputSchema title from role name
When uwf workflow put processes inline JSON Schema for a role,
auto-inject title=roleName if not already set. Makes uwf cas schema list
show meaningful names like 'planner', 'coder' instead of (unnamed).

小橘 🍊(NEKO Team)
2026-05-18 13:05:28 +00:00
xiaoju c9010a024f fix: remove cas list, add title to schemas
- Remove uwf cas list (CAS grows unbounded, listing all hashes is useless)
- Add title to Workflow/StartNode/StepNode schemas so schema list shows names

小橘 🍊(NEKO Team)
2026-05-18 13:01:17 +00:00
xiaomo 3434e2b2be Merge pull request 'feat: built-in uwf cas commands replacing json-cas passthrough' (#323) from feat/319-uwf-cas-builtin into main 2026-05-18 12:49:18 +00:00
xiaoju 52282e1960 feat: built-in uwf cas commands replacing json-cas passthrough
- get, cat, put, has, list, refs, walk, schema list, schema get
- All commands auto-resolve store to ~/.uncaged/workflow/cas
- No external json-cas CLI dependency needed
- Agent-friendly: uwf cas --help shows all available subcommands

Refs #319, Closes #320

小橘 🍊(NEKO Team)
2026-05-18 12:40:15 +00:00
Scott Wei 7a579ee67a feat: uwf cas — passthrough to json-cas with uwf store path
uwf cas get <hash>, uwf cas list, etc. all auto-set --store to
~/.uncaged/workflow/cas so agents don't need to remember the path.

小橘 🍊(NEKO Team)
2026-05-18 20:14:59 +08:00
Scott Wei 7c230383ad improve: multi-column model list + friendly post-setup message
- Model list now renders in columns to fit terminal width
- Interactive setup ends with usage hints instead of JSON dump

小橘 🍊(NEKO Team)
2026-05-18 19:56:09 +08:00
xiaoju e604fa5f47 feat: add uwf setup command
- Interactive mode: prompts for provider, API key, model (with /models discovery)
- Non-interactive mode: --provider --base-url --api-key --model flags
- Writes config.yaml (providers, models, agents, defaults)
- Writes .env (API keys with auto-generated env var names)
- Merges into existing config non-destructively
- Includes 13 preset providers (international + China + local)

小橘 🍊(NEKO Team)
2026-05-18 11:49:42 +00:00
xiaoju 5580791686 chore: remove stale develop-entry.ts 2026-05-18 11:43:09 +00:00
xiaoju 3afd7a5319 chore: remove leftover smoke test files 2026-05-18 11:41:49 +00:00
xiaoju 3d1b2268b4 chore: bump json-cas deps to ^0.1.3 2026-05-18 10:48:06 +00:00
xiaoju 8bebe9da0f chore: bump json-cas-fs to ^0.1.2 (fix workspace:^ in published pkg) 2026-05-18 10:44:30 +00:00
xiaoju 53a7355f0b chore: fix json-cas workspace:^ refs to ^0.1.1 2026-05-18 10:30:31 +00:00
xiaoju d99c285725 chore: remove cross-repo json-cas workspace deps from root 2026-05-18 10:28:22 +00:00
xiaoju 2505dd8d6a chore: remove stale pnpm-lock.yaml 2026-05-18 10:25:45 +00:00
xiaomo 1121dfa48b Merge pull request 'feat: uwf — Stateless Workflow CLI' (#317) from feat/309-uwf-stateless into main 2026-05-18 10:07:55 +00:00
xiaoju d90e29ad05 fix: address 3 critical PR review issues
1. threads.yaml race condition: reload threads index after agent subprocess
   completes before updating head pointer (cli-uwf/commands/thread.ts)

2. evaluateJsonata not awaited: jsonata evaluate() returns Promise for async
   expressions — now properly awaited (uwf-moderator/evaluate.ts)

3. resolveWorkflowHash dead code: function always returns a value, removed
   impossible null return type and dead null-check branches at call sites
   (cli-uwf/store.ts, commands/thread.ts, commands/workflow.ts)
2026-05-18 10:05:11 +00:00
xiaoju 0727e0e8d5 fix: reload CAS store after agent spawn + share schemas via uwf-protocol
The agent subprocess writes StepNode to CAS on disk, but the parent
process had an in-memory cache from createFsStore init. Fix: re-create
store after agent spawn to pick up new nodes.

Also centralized JSON Schemas in uwf-protocol so cli-uwf and agent-kit
produce identical type hashes.

E2E smoke test passing: workflow put → thread start → 3x step → done

Refs #309
2026-05-18 09:33:52 +00:00
xiaoju ba012d98bc feat: add @uncaged/uwf-agent-hermes — Hermes agent CLI adapter
Spawns 'hermes chat' with assembled prompt from agent-kit context.
Agent-kit handles extract, StepNode write, and stdout output.

Refs #309, #316
2026-05-18 09:22:12 +00:00
xiaoju b165049a13 feat: implement thread step — moderator → agent → update head
- Walk CAS chain to build ModeratorContext with expanded output
- Call uwf-moderator evaluate() for role decision
- Agent resolution: --agent > config overrides > default
- Spawn agent CLI, capture StepNode hash
- Update threads.yaml, check done via second evaluate
- Archive on $END

Refs #309, #315
2026-05-18 09:19:37 +00:00
xiaoju 4d477c67c0 feat: add @uncaged/uwf-agent-kit — agent CLI framework
- createAgent() API for building agent CLIs
- Context builder: reads CAS chain, builds AgentContext
- Extract: LLM-based structured output extraction
- StepNode writer: writes to CAS without touching threads.yaml
- Stdout: outputs StepNode hash

Refs #309, #314
2026-05-18 09:15:25 +00:00
xiaoju 0d5678c961 feat: add thread start/show/list/kill commands
- thread start: ULID generation, StartNode to CAS, threads.yaml
- thread show: active (done:false) or archived (done:true)
- thread list: active threads, --all includes history
- thread kill: archive to history.jsonl

Refs #309, #313
2026-05-18 09:09:10 +00:00
xiaoju a8e2aa85f8 feat: add @uncaged/cli-uwf with workflow put/show/list commands
Refs #309, #312
2026-05-18 09:03:55 +00:00
xiaoju 2a4d35399b feat: add @uncaged/uwf-moderator with JSONata evaluation engine
5 tests passing:  transition, condition match, fallback,
missing role error, output expansion.

Refs #309, #311
2026-05-18 08:58:21 +00:00
xiaoju 391915411e feat: add @uncaged/uwf-protocol with all shared types
Refs #309, #310
2026-05-18 08:53:37 +00:00
scottwei 4aaf49bfc6 Merge pull request 'jshang/optimize-dashboard-ui' (#308) from jshang/optimize-dashboard-ui into main
Reviewed-on: #308
2026-05-18 08:45:46 +00:00
xiaoju 08de1ae5eb docs: fresh uwf-* packages, depend on @uncaged/json-cas, no reuse 2026-05-18 08:44:04 +00:00
xiaoju c91a3d1ec6 docs: add description to condition definitions 2026-05-18 08:41:29 +00:00
xiaoju 13d932f69c docs: config with provider/model/agent registries and alias-based overrides 2026-05-18 08:38:08 +00:00
jiashuang f705d9b8ea refactor: optimize ui for dashboard 2026-05-18 16:20:05 +08:00
xiaoju f84d327410 docs: add .env for API keys, separate from config.yaml 2026-05-18 08:19:48 +00:00
xiaoju 9c2f93629b docs: add models config (default + extract LLM) 2026-05-18 08:16:03 +00:00
xiaoju bcefcb9af7 docs: add section 4 — key data types with shared StepRecord 2026-05-18 08:13:18 +00:00
xiaoju b14dce2bc6 docs: fix inconsistencies — title, terminology, threads.yaml, JSONata context 2026-05-18 08:09:40 +00:00
xiaoju 85c572e770 docs: inline roles/moderator into Workflow, output as cas_ref, detail polymorphic 2026-05-18 08:07:20 +00:00
xiaoju 9a89885ce6 docs: rewrite CAS structure — flatten refs, named conditions, config.yaml, output naming 2026-05-18 07:55:04 +00:00
xiaoju d095ceaafa docs: agent CLI takes thread-id + role, outputs CAS hash, step owns pointer 2026-05-18 07:24:14 +00:00
xiaoju 2a0346f48b docs: simplify show to pure thread-id → head query, all output JSON 2026-05-18 07:18:29 +00:00
xiaoju b4e25ea002 docs: add done field to step output 2026-05-18 07:12:38 +00:00
xiaoju 77f2060e6b docs: step on ended thread is an error, not null head 2026-05-18 07:11:50 +00:00
xiaoju 8f9a925179 docs: simplify step output to workflow/thread/head 2026-05-18 07:10:11 +00:00
jiashuang 2f3fff3536 refactor: introduce react-router 2026-05-18 15:06:16 +08:00
xiaoju a7eb9814ae docs: fix agent invocation format in thread step 2026-05-18 07:05:35 +00:00
xiaoju a8024e6d42 docs: use full 26-char Crockford Base32 ULIDs for thread IDs 2026-05-18 07:03:40 +00:00
xiaoju 6d94d9c85a docs: fix hash format to 13-char Crockford Base32 (XXH64) 2026-05-18 07:03:02 +00:00
xiaoju 49a4d08c04 docs: add thread list --all and thread kill 2026-05-18 06:59:47 +00:00
xiaoju d5773369af docs: uwf thread subcommands, simplify start output 2026-05-18 06:58:35 +00:00
xiaoju f49e014f41 docs: update CLI design — uwf naming, simplify commands and agent protocol 2026-05-18 06:56:55 +00:00
xiaoju ab48a8169d docs: add stateless workflow CLI design
Refs #297
2026-05-18 06:37:25 +00:00
xiaomo 2b707fb44e Merge pull request 'refactor: replace extractRefs with schema casRef annotations (Phase 2)' (#290) from feat/285-phase2-remove-extractrefs into main 2026-05-16 11:50:47 +00:00
xiaoju 6306b23a9f refactor: replace extractRefs with schema casRef annotations
Migrate all templates to use .meta({ casRef: true }) on Zod schema
fields instead of manual extractRefs functions. Remove extractRefs
from RoleDefinition type entirely.

- develop: planner phases[].hash, coder completedPhase annotated
- solve-issue, smoke, init templates: extractRefs removed
- create-workflow.ts: uses collectCasRefs(schema, meta)
- RoleDefinition: extractRefs field removed (breaking)

218 tests pass, 0 fail.

Fixes #289, Refs #285
2026-05-16 10:48:45 +00:00
xiaomo 6bb8cf8315 Merge pull request 'feat: add collectCasRefs — schema-level CAS ref annotation (Phase 1)' (#288) from feat/285-cas-ref-annotation into main 2026-05-16 10:43:16 +00:00
xiaoju 93b7947d7c feat: add collectCasRefs — extract CAS refs from schema meta annotations
Replaces manual extractRefs functions with declarative schema-level
casRef annotations. Walks Zod v4 schemas recursively, collecting
string values from fields marked with .meta({ casRef: true }).

Supports: objects, arrays, discriminatedUnion, nullable/optional.

8/8 test cases pass (flat, nested, union, null, mixed).

Refs #285, addresses #286
2026-05-16 10:42:24 +00:00
xingyue 9584a86fb7 Merge pull request 'chore: fix biome cognitive complexity warnings' (#287) from chore/fix-biome-complexity-warnings into main 2026-05-16 10:40:27 +00:00
Scott Wei defc0afc27 chore: fix biome cognitive complexity warnings
Refactor dashboard graph/schema helpers and descriptor role validation
into smaller functions so bun run check passes without warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:33:00 +08:00
xiaomo 9f6633d5bf Merge pull request 'refactor(workflow-protocol): require AgentFn Opt generic' (#284) from refactor/agent-fn-required-opt into main 2026-05-16 10:27:07 +00:00
Scott Wei 7dadf874e1 refactor(workflow-protocol): require AgentFn Opt generic
Make AgentFn<Opt> always take a mandatory options argument, removing
the void conditional overload. Simplify createAgentAdapter, restore
exports needed by tests, and fix CLI test bundles to use cas.put
instead of disallowed @uncaged/* imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:23:07 +08:00
161 changed files with 12171 additions and 1391 deletions
+2
View File
@@ -9,3 +9,5 @@ bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
-16
View File
@@ -1,16 +0,0 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
+5 -2
View File
@@ -8,7 +8,7 @@
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
## Package map
@@ -26,10 +26,13 @@ Grouped by responsibility (npm name → folder).
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
| Agent adapters | `@uncaged/workflow-agent-cursor``workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
| | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
| | `@uncaged/workflow-agent-office``workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
| | `@uncaged/workflow-agent-docx-diff``workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
| | `@uncaged/workflow-agent-llm``workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
| Agent shared | `@uncaged/workflow-util-agent``workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
| Templates | `@uncaged/workflow-template-develop``workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
| | `@uncaged/workflow-template-solve-issue``workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
| | `@uncaged/workflow-template-document``workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
| Dashboard | `@uncaged/workflow-dashboard``workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
## Dependency graph (workspace packages)
@@ -265,4 +268,4 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
| **Single-file ESM** | Hash = version, self-contained bundle |
| **No daemon** | OS handles process lifecycle |
| **Crockford Base32** | Filesystem-safe, readable, compact |
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
+527
View File
@@ -0,0 +1,527 @@
# `uwf` — Stateless Workflow CLI
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
---
## 1. CLI Design
### 1.1 命令总览
```
# thread 组
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
uwf thread step <thread-id> [--agent] # 单步执行
uwf thread show <thread-id> # thread-id → head 查询
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
uwf thread kill <thread-id> # 终结 thread,归档
# workflow 组
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
```bash
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
```
- `<workflow>` — workflow 名或 CAS hash
- `-p` — 用户 prompt(必填)
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
}
```
**做的事:**
1. 解析 workflow(名字查 registry → CAS hash)
2. 生成 thread ULID
3. 写 StartNode 到 CAS
4. 在 threads.yaml 中记录链头 → StartNode hash
5. 输出 JSON
### 1.3 `uwf thread step`
```bash
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
"done": false // true = moderator 返回 END,thread 已归档
}
```
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
详细信息通过 `uwf thread show <thread-id>``json-cas get <head>` 查看。
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
4. 若 END → 归档 thread,输出最后链头,退出
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
7. 更新链头指针
8. 再次调 moderator(基于新 StepNode)判断 done
9. 输出 JSON
### 1.4 `uwf thread show`
```bash
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA",
"done": false
}
```
纯 thread-id → head 查询。详细内容用 `json-cas get <head>``json-cas walk <head>` 查看。
### 1.5 Agent CLI 协议
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
```bash
uwf-hermes <thread-id> <role>
```
**约定:**
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
- agent 执行实际逻辑,agent-kit 负责 extract
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
- 所有配置从环境变量读(LLM model、API key、extractor config)
- exit 0 = 成功,非 0 = 失败
**stdout 输出:**
```
8FWKR3TN5V1QA
```
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
---
## 2. CAS 结构定义
### 2.1 类型层级
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent..."
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
developer:
description: "Implements code changes"
systemPrompt: "You are a developer agent..."
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
systemPrompt: "You are a code reviewer..."
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
expression: "$exists(steps[-1].output.needsClarification)"
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null # 无条件(fallback)
planner:
- role: "developer"
condition: "needsClarification"
- role: "$END"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `conditions``Record<Name, JSONata>`,命名条件,方便画图描述
- `graph``Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
- `condition` 引用 conditions 中的 key,`null` = fallback
- 按数组顺序求值,第一个匹配的 transition 胜出
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
JSONata 表达式的求值上下文:
```jsonc
{
"start": { // StartNode 信息
"workflow": "4KNM2PXR3B1QW",
"prompt": "Fix the login bug..."
},
"steps": [ // 所有已完成 steps,从旧到新
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
]
}
```
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
#### `StartNode`(Thread 起点)
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
- 没有 agent binding — 运行时从 config.yaml 解析
#### `StepNode`(Thread 每一步)
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
```
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → CAS(Workflow)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── start ──→ (same StartNode)
│ ├── prev ──→ StepNode (step 1)
│ │ ├── start ──→ (same StartNode)
│ │ ├── prev: null
│ │ ├── role: "planner"
│ │ └── ...
│ ├── role: "developer"
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(raw output | sub-workflow terminal node)
└── agent: "uwf-hermes"
```
### 2.4 可变状态
系统两个顶层 YAML 文件和一个 env 文件:
```yaml
# ~/.uncaged/workflow/config.yaml — 全局配置
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKeyEnv: "OPENAI_API_KEY"
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKeyEnv: "ANTHROPIC_API_KEY"
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
```yaml
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
```
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
```bash
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
```
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
- `threads.yaml` — 运行时状态
---
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`
```
packages/
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
```
**外部依赖:**
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
---
## 4. 关键数据类型
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
```typescript
/** CAS hash — XXH64, 13-char Crockford Base32 */
type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
### 4.2 Workflow 定义
```typescript
type RoleDefinition = {
description: string;
systemPrompt: string;
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Transition = {
role: string; // 目标 role 名 或 "$END"
condition: string | null; // 引用 conditions 中的 key,null = fallback
};
type ConditionDefinition = {
description: string;
expression: string; // JSONata expression
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
};
```
### 4.3 Thread 节点
```typescript
type StartNodePayload = {
workflow: CasRef; // cas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
};
```
### 4.4 JSONata 求值上下文
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
```typescript
/** JSONata 上下文中的 step — output 被展开 */
type StepContext = Omit<StepRecord, "output"> & {
output: unknown; // 展开后的 CAS 节点内容,非 hash
};
type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[]; // 从旧到新
};
```
### 4.5 CLI 输出
```typescript
/** uwf thread start */
type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread list */
type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
```
### 4.6 配置
```typescript
/** Alias types for config references */
type AgentAlias = string;
type ModelAlias = string;
type ProviderAlias = string;
type WorkflowName = string;
type RoleName = string;
type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string; // env var name to read API key from
};
type ModelConfig = {
provider: ProviderAlias;
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
};
type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uncaged/workflow/config.yaml */
type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uncaged/workflow/threads.yaml */
type ThreadsIndex = Record<ThreadId, CasRef>;
// ^ thread-id ^ head StepNode/StartNode hash
```
### 4.7 类型关系图
```
WorkflowConfig (config.yaml)
ThreadsIndex (threads.yaml) ← 唯二可变状态
│ thread-id → head hash
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
│ │ │
├── start → StartNodePayload│ │ (output 展开)
├── prev → StepNodePayload │ │
│ ├── role ├── role
│ ├── output (CasRef) ├── output (展开)
│ ├── detail (CasRef) ├── detail (CasRef)
│ └── agent (string) └── agent (string)
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
├── conditions: Record<name, JSONata>
└── graph: Record<role, Transition[]>
```
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@uncaged/cli-uwf",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf": "./src/cli.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@uncaged/uwf-agent-kit": "workspace:^",
"@uncaged/uwf-moderator": "workspace:^",
"@uncaged/uwf-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"commander": "^14.0.3",
"dotenv": "^16.6.1",
"yaml": "^2.8.4"
},
"scripts": {
"test": "vitest run"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"vitest": "^4.1.6"
}
}
@@ -0,0 +1,391 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/uwf-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
cmdThreadRead,
cmdThreadStepDetails,
extractLastAssistantContent,
THREAD_READ_DEFAULT_QUOTA,
} from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── extractLastAssistantContent ───────────────────────────────────────────────
describe("extractLastAssistantContent", () => {
test("returns last non-empty assistant content from turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const turn1 = await uwf.store.put(schemas.turn, {
index: 0,
role: "assistant",
content: "intermediate",
toolCalls: null,
reasoning: null,
});
const turn2 = await uwf.store.put(schemas.turn, {
index: 1,
role: "tool",
content: "ok",
toolCalls: null,
reasoning: null,
});
const turn3 = await uwf.store.put(schemas.turn, {
index: 2,
role: "assistant",
content: "final answer",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s1",
model: "m1",
duration: 1000,
turnCount: 3,
turns: [turn1, turn2, turn3],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBe("final answer");
});
test("returns null when detail node does not exist in store", async () => {
const uwf = await makeUwfStore(tmpDir);
expect(extractLastAssistantContent(uwf, "nonexistent00" as CasRef)).toBeNull();
});
test("returns null when turns array is empty", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s2",
model: "m2",
duration: 0,
turnCount: 0,
turns: [],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBeNull();
});
test("returns null when all assistant turns have empty content", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const turn1 = await uwf.store.put(schemas.turn, {
index: 0,
role: "assistant",
content: "",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s3",
model: "m3",
duration: 0,
turnCount: 1,
turns: [turn1],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBeNull();
});
test("skips whitespace-only assistant content and returns earlier match", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const turn1 = await uwf.store.put(schemas.turn, {
index: 0,
role: "assistant",
content: "real content",
toolCalls: null,
reasoning: null,
});
const turn2 = await uwf.store.put(schemas.turn, {
index: 1,
role: "assistant",
content: " ",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s4",
model: "m4",
duration: 0,
turnCount: 2,
turns: [turn1, turn2],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBe("real content");
});
});
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
describe("cmdThreadRead ### Content section", () => {
test("includes ### Content before ### Output when detail has assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Write",
systemPrompt: "You are a writer.",
outputSchema: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "The assistant response text",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: detailHash,
agent: "uwf-hermes",
});
const threadId = "01JTEST0000000000000000001" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).toContain("### Content");
expect(markdown).toContain("The assistant response text");
const contentIdx = markdown.indexOf("### Content");
const outputIdx = markdown.indexOf("### Output");
expect(contentIdx).toBeGreaterThanOrEqual(0);
expect(outputIdx).toBeGreaterThanOrEqual(0);
expect(contentIdx).toBeLessThan(outputIdx);
});
test("omits ### Content when detail has no matching assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf2",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// A detail ref that doesn't exist in the store → extractLastAssistantContent returns null
const missingDetailRef = "missingdetail0" as CasRef;
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: missingDetailRef,
agent: "uwf-hermes",
});
const threadId = "01JTEST0000000000000000002" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).not.toContain("### Content");
expect(markdown).toContain("### Output");
});
});
// ── cmdThreadStepDetails ──────────────────────────────────────────────────────
describe("cmdThreadStepDetails", () => {
test("returns expanded detail node with turns inlined", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "wf",
description: "",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "p",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "done",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sess42",
model: "gpt-4o",
duration: 3000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "coder",
output: outputHash,
detail: detailHash,
agent: "uwf-hermes",
});
const result = await cmdThreadStepDetails(tmpDir, stepHash);
expect(result).toMatchObject({
sessionId: "sess42",
model: "gpt-4o",
duration: 3000,
turnCount: 1,
});
const expanded = result as Record<string, unknown>;
expect(Array.isArray(expanded.turns)).toBe(true);
const turns = expanded.turns as unknown[];
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({
index: 0,
role: "assistant",
content: "done",
});
});
test("throws when step hash does not exist", async () => {
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
});
});
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env bun
import type { ThreadId } from "@uncaged/uwf-protocol";
import { Command } from "commander";
import { stringify as yamlStringify } from "yaml";
import {
cmdCasGet,
cmdCasHas,
cmdCasPut,
cmdCasRefs,
cmdCasReindex,
cmdCasSchemaGet,
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdThreadFork,
cmdThreadKill,
cmdThreadList,
cmdThreadRead,
cmdThreadShow,
cmdThreadStart,
cmdThreadStep,
cmdThreadStepDetails,
cmdThreadSteps,
THREAD_READ_DEFAULT_QUOTA,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { formatOutput, type OutputFormat } from "./format.js";
import { resolveStorageRoot } from "./store.js";
function writeOutput(data: unknown): void {
const fmt = program.opts().format as OutputFormat;
process.stdout.write(`${formatOutput(data, fmt)}\n`);
}
function runAction(action: () => Promise<void>): void {
action().catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
process.exit(1);
});
}
const program = new Command();
program.name("uwf").description("Stateless workflow CLI");
program.option("--format <fmt>", "Output format: json or yaml", "json");
const workflow = program.command("workflow").description("Workflow registry and CAS");
workflow
.command("put")
.description("Register a workflow from YAML")
.argument("<file>", "Workflow YAML file")
.action((file: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowPut(storageRoot, file);
writeOutput(result);
});
});
workflow
.command("show")
.description("Show a workflow by name or CAS hash")
.argument("<id>", "Workflow name or hash")
.action((id: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowShow(storageRoot, id);
writeOutput(result);
});
});
workflow
.command("list")
.description("List registered workflows")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowList(storageRoot);
writeOutput(result);
});
});
const thread = program.command("thread").description("Thread lifecycle and execution");
thread
.command("start")
.description("Create a thread without executing")
.argument("<workflow>", "Workflow name or hash")
.requiredOption("-p, --prompt <text>", "User prompt")
.action((workflow: string, opts: { prompt: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
writeOutput(result);
});
});
thread
.command("step")
.description("Execute one step")
.argument("<thread-id>", "Thread ULID")
.option("--agent <cmd>", "Override agent command")
.action((threadId: string, opts: { agent: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
writeOutput(result);
});
});
thread
.command("show")
.description("Show thread head pointer")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadShow(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("list")
.description("List active threads")
.option("--all", "Include archived threads")
.action((opts: { all: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadList(storageRoot, opts.all);
writeOutput(result);
});
});
thread
.command("kill")
.description("Terminate and archive a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadKill(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("steps")
.description("List all steps in a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadSteps(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("read")
.description("Read thread context as human-readable markdown")
.argument("<thread-id>", "Thread ULID")
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
.option("--start", "Include start step in output")
.action(
(threadId: string, opts: { quota: string; before: string | undefined; start: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const quota = Number.parseInt(opts.quota, 10);
if (!Number.isFinite(quota) || quota < 1) {
process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1);
}
const before = opts.before ?? null;
const markdown = await cmdThreadRead(
storageRoot,
threadId as ThreadId,
quota,
before,
opts.start ?? false,
);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
},
);
thread
.command("fork")
.description("Fork a thread from a specific step")
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadFork(storageRoot, stepHash);
writeOutput(result);
});
});
thread
.command("step-details")
.description("Dump the full detail node of a step as YAML")
.argument("<step-hash>", "CAS hash of the StepNode")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const detail = await cmdThreadStepDetails(storageRoot, stepHash);
process.stdout.write(yamlStringify(detail));
});
});
program
.command("setup")
.description("Configure provider, model, and agent")
.option("--provider <name>", "Provider name")
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
.option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent alias")
.action(
(opts: {
provider?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
agent?: string;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
const result = await cmdSetup({
provider: opts.provider,
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
model: opts.model,
agent: opts.agent ?? undefined,
storageRoot,
});
writeOutput(result);
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
await cmdSetupInteractive(storageRoot);
} else {
throw new Error(
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
);
}
});
},
);
const cas = program.command("cas").description("Content-addressable storage operations");
cas
.command("get")
.description("Read a CAS node (type + payload; use --timestamp to include timestamp)")
.argument("<hash>", "CAS hash (13 char)")
.option("--timestamp", "Include timestamp in output")
.action((hash: string, opts: { timestamp?: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasGet(storageRoot, hash, opts));
});
});
cas
.command("put")
.description("Store a node, print its hash")
.argument("<type-hash>", "Type (schema) hash")
.argument("<data>", "JSON file path or inline JSON string")
.action((typeHash: string, data: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasPut(storageRoot, typeHash, data));
});
});
cas
.command("has")
.description("Check if a hash exists")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasHas(storageRoot, hash));
});
});
cas
.command("refs")
.description("List direct CAS references from a node")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasRefs(storageRoot, hash));
});
});
cas
.command("walk")
.description("Recursive traversal from a node")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasWalk(storageRoot, hash));
});
});
cas
.command("reindex")
.description("Rebuild type index from all CAS nodes")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasReindex(storageRoot));
});
});
const casSchema = cas.command("schema").description("CAS schema operations");
casSchema
.command("list")
.description("List all registered schemas")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasSchemaList(storageRoot));
});
});
casSchema
.command("get")
.description("Show a schema by its type hash")
.argument("<hash>", "Schema type hash")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasSchemaGet(storageRoot, hash));
});
});
program.parseAsync(process.argv).catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
process.exit(1);
});
+139
View File
@@ -0,0 +1,139 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Helpers ----
function openStore(storageRoot: string): Store {
return createFsStore(join(storageRoot, "cas"));
}
function readJsonArg(fileOrInline: string): unknown {
try {
return JSON.parse(fileOrInline);
} catch {
try {
return JSON.parse(readFileSync(fileOrInline, "utf-8"));
} catch (e) {
throw new Error(`Cannot parse JSON from "${fileOrInline}": ${e}`);
}
}
}
// ---- Commands (all return JSON-serializable data) ----
export async function cmdCasGet(
storageRoot: string,
hash: string,
opts: { timestamp?: boolean },
): Promise<unknown> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
if (opts.timestamp) {
return node;
}
const { timestamp: _, ...rest } = node as Record<string, unknown>;
return rest;
}
export async function cmdCasPut(
storageRoot: string,
typeHash: string,
data: string,
): Promise<{ hash: string }> {
const store = openStore(storageRoot);
const payload = readJsonArg(data);
const hash = await store.put(typeHash, payload);
return { hash };
}
export async function cmdCasHas(
storageRoot: string,
hash: string,
): Promise<{ exists: boolean }> {
const store = openStore(storageRoot);
return { exists: store.has(hash) };
}
export async function cmdCasRefs(
storageRoot: string,
hash: string,
): Promise<{ refs: string[] }> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
return { refs: refs(store, node) };
}
export async function cmdCasWalk(
storageRoot: string,
hash: string,
): Promise<{ hashes: string[] }> {
const store = openStore(storageRoot);
const result: string[] = [];
walk(store, hash, (h) => {
result.push(h);
});
return { hashes: result };
}
export type SchemaListEntry = {
hash: string;
title: string;
};
export async function cmdCasSchemaList(
storageRoot: string,
): Promise<SchemaListEntry[]> {
const store = openStore(storageRoot);
const metaHash = await bootstrap(store);
const entries: SchemaListEntry[] = [];
// Include meta-schema itself
entries.push({ hash: metaHash, title: "(meta-schema)" });
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
const schema = node.payload as JSONSchema;
const title =
(schema.title as string | undefined) ??
(schema.description as string | undefined) ??
"(unnamed)";
entries.push({ hash, title });
}
}
return entries;
}
export async function cmdCasReindex(
storageRoot: string,
): Promise<{ status: string }> {
const indexDir = join(storageRoot, "cas", "_index");
const { rmSync } = await import("node:fs");
rmSync(indexDir, { recursive: true, force: true });
// Re-open store to trigger migration rebuild
openStore(storageRoot);
return { status: "reindexed" };
}
export async function cmdCasSchemaGet(
storageRoot: string,
hash: string,
): Promise<unknown> {
const store = openStore(storageRoot);
const schema = getSchema(store, hash);
if (schema === null) {
throw new Error(`Schema not found: ${hash}`);
}
return schema;
}
+332
View File
@@ -0,0 +1,332 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { stringify, parse } from "yaml";
/**
* Preset provider list — embedded to avoid runtime YAML loading dependency.
* Keep in sync with providers.yaml in cli-workflow.
*/
const PRESET_PROVIDERS = [
// International
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
// China
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
// Local
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
] as const;
type SetupArgs = {
provider: string;
baseUrl: string;
apiKey: string;
model: string;
agent?: string | undefined;
storageRoot: string;
};
function getConfigPath(root: string): string {
return join(root, "config.yaml");
}
function getEnvPath(root: string): string {
return join(root, ".env");
}
/**
* Load existing config.yaml or return empty structure.
*/
function loadExistingConfig(configPath: string): Record<string, unknown> {
try {
if (existsSync(configPath)) {
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
}
} catch {
// ignore parse errors, start fresh
}
return {};
}
/**
* Load existing .env as key=value map.
*/
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
try {
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
}
}
} catch {
// ignore
}
return env;
}
function saveEnvFile(envPath: string, env: Record<string, string>): void {
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
}
function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
/**
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
*/
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
const providers = (typeof existing.providers === "object" && existing.providers !== null
? { ...(existing.providers as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
const models = (typeof existing.models === "object" && existing.models !== null
? { ...(existing.models as Record<string, unknown>) }
: {}) as Record<string, unknown>;
models.default = { provider: args.provider, name: args.model };
const agents = (typeof existing.agents === "object" && existing.agents !== null
? { ...(existing.agents as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
agents.hermes = { command: "uwf-hermes", args: [] };
}
return {
...existing,
providers,
models,
agents,
defaultAgent: existing.defaultAgent ?? agentName,
defaultModel: existing.defaultModel ?? "default",
};
}
/**
* Non-interactive setup. All required args provided via CLI flags.
*/
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
const { storageRoot } = args;
mkdirSync(storageRoot, { recursive: true });
const configPath = getConfigPath(storageRoot);
const envPath = getEnvPath(storageRoot);
const existing = loadExistingConfig(configPath);
const merged = mergeConfig(existing, args);
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Write API key to .env
const envName = apiKeyEnvName(args.provider);
const envData = loadEnvFile(envPath);
envData[envName] = args.apiKey;
saveEnvFile(envPath, envData);
return {
configPath,
envPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
};
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((resolve) => {
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
let buf = "";
const onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
resolve(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
if (buf.length > 0) {
buf = buf.slice(0, -1);
process.stdout.write("\b \b");
}
continue;
}
if (c === "\u0003") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.exit(130);
}
buf += c;
process.stdout.write("*");
}
};
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
try {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return [];
const body = (await res.json()) as { data?: { id: string }[] };
if (!Array.isArray(body.data)) return [];
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
} catch {
return [];
}
}
/**
* Interactive setup — prompts user for provider, API key, model.
*/
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
const rl = createInterface({ input, output });
try {
console.log("Configure LLM provider for uwf workflow agents.\n");
// 1. Provider selection
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
console.log("Select a provider:\n");
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
const p = PRESET_PROVIDERS[i];
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
throw new Error(`Invalid choice: ${choice}`);
}
let providerName: string;
let baseUrl: string;
if (choiceNum <= PRESET_PROVIDERS.length) {
const selected = PRESET_PROVIDERS[choiceNum - 1];
if (!selected) throw new Error("Invalid selection");
providerName = selected.name;
baseUrl = selected.baseUrl;
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
} else {
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
if (!providerName) throw new Error("Provider name required");
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
if (!baseUrl) throw new Error("Base URL required");
}
// 2. API key
rl.close();
const apiKey = await promptSecret("API key: ");
if (!apiKey) throw new Error("API key required");
// 3. Model selection
const rl2 = createInterface({ input, output });
console.log("\nFetching available models...");
const models = await fetchModels(baseUrl, apiKey);
let model: string;
if (models.length > 0) {
console.log(`\nAvailable models (${models.length}):\n`);
const nw = String(models.length).length;
// Multi-column layout
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
const colWidth = nw + 2 + maxLen + 4; // " N) name "
const termCols = process.stdout.columns || 100;
const cols = Math.max(1, Math.floor(termCols / colWidth));
const rows = Math.ceil(models.length / cols);
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx >= models.length) break;
const num = String(idx + 1).padStart(nw);
const name = (models[idx] ?? "").padEnd(maxLen);
line += ` ${num}) ${name} `;
}
console.log(line.trimEnd());
}
console.log(`\nChoose a number, or type a model name directly.`);
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
if (!modelInput) throw new Error("Model required");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
model = models[modelNum - 1] ?? modelInput;
} else {
model = modelInput;
}
} else {
console.log("Could not fetch models. Enter model name manually.");
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
if (!model) throw new Error("Model required");
}
rl2.close();
console.log(`${providerName}/${model}\n`);
await cmdSetup({
provider: providerName,
baseUrl,
apiKey,
model,
storageRoot,
});
console.log("Setup complete! Get started:\n");
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
console.log(' uwf thread start <name> -p "..." Start a thread');
console.log(" uwf thread step <thread-id> Execute next step");
console.log("");
return null as unknown as Record<string, unknown>;
} finally {
rl.close();
}
}
+844
View File
@@ -0,0 +1,844 @@
import { execFileSync } from "node:child_process";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
import { evaluate } from "@uncaged/uwf-moderator";
import type {
AgentAlias,
AgentConfig,
CasRef,
ModeratorContext,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/uwf-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { stringify } from "yaml";
import {
appendThreadHistory,
createUwfStore,
findThreadInHistory,
loadThreadHistory,
loadThreadsIndex,
loadWorkflowRegistry,
resolveWorkflowHash,
saveThreadsIndex,
type ThreadHistoryLine,
type UwfStore,
} from "../store.js";
import { isCasRef } from "../validate.js";
const END_ROLE = "$END";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
type OrderedStepItem = {
hash: CasRef;
payload: StepNodePayload;
timestamp: number;
};
export type KillOutput = {
thread: ThreadId;
archived: boolean;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
async function resolveWorkflowCasRef(
uwf: UwfStore,
storageRoot: string,
workflowId: string,
): Promise<CasRef> {
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, workflowId);
if (!isCasRef(hash)) {
fail(`workflow not found: ${workflowId}`);
}
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found: ${hash}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${hash} is not a Workflow (type ${node.type})`);
}
return hash;
}
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
const node = uwf.store.get(head);
if (node === null) {
return null;
}
if (node.type === uwf.schemas.startNode) {
const payload = node.payload as StartNodePayload;
return payload.workflow;
}
const payload = node.payload as StepNodePayload;
if (typeof payload.start !== "string") {
return null;
}
const startNode = uwf.store.get(payload.start);
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
return null;
}
return (startNode.payload as StartNodePayload).workflow;
}
export async function cmdThreadStart(
storageRoot: string,
workflowId: string,
prompt: string,
): Promise<StartOutput> {
const uwf = await createUwfStore(storageRoot);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
const threadId = generateUlid(Date.now()) as ThreadId;
const startPayload: StartNodePayload = {
workflow: workflowHash,
prompt,
};
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
const node = uwf.store.get(headHash);
if (node === null || !validate(uwf.store, node)) {
fail("stored StartNode failed schema validation");
}
const index = await loadThreadsIndex(storageRoot);
index[threadId] = headHash;
await saveThreadsIndex(storageRoot, index);
return { workflow: workflowHash, thread: threadId };
}
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, activeHead);
if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`);
}
return {
workflow,
thread: threadId,
head: activeHead,
done: false,
};
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return {
workflow: hist.workflow,
thread: threadId,
head: hist.head,
done: true,
};
}
fail(`thread not found: ${threadId}`);
}
async function threadListItemFromActive(
uwf: UwfStore,
threadId: ThreadId,
head: CasRef,
): Promise<ThreadListItem | null> {
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
return null;
}
return { thread: threadId, workflow, head };
}
export async function cmdThreadList(
storageRoot: string,
includeAll: boolean,
): Promise<ThreadListItem[]> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const items: ThreadListItem[] = [];
for (const [threadId, head] of Object.entries(index)) {
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
if (item !== null) {
items.push(item);
}
}
if (!includeAll) {
return items;
}
const activeIds = new Set(items.map((i) => i.thread));
const history = await loadThreadHistory(storageRoot);
for (const entry of history) {
if (!activeIds.has(entry.thread)) {
items.push({
thread: entry.thread,
workflow: entry.workflow,
head: entry.head,
});
}
}
return items;
}
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
const headNode = uwf.store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
}
if (headNode.type === uwf.schemas.startNode) {
return {
startHash: headHash,
start: headNode.payload as StartNodePayload,
stepsNewestFirst: [],
headIsStart: true,
};
}
if (headNode.type !== uwf.schemas.stepNode) {
fail(`head ${headHash} is not a StartNode or StepNode`);
}
const stepsNewestFirst: StepNodePayload[] = [];
let hash: CasRef | null = headHash;
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found while walking chain: ${hash}`);
}
if (node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
stepsNewestFirst.push(payload);
hash = payload.prev;
}
const newest = stepsNewestFirst[0];
if (newest === undefined) {
fail(`empty step chain at head ${headHash}`);
}
const startNode = uwf.store.get(newest.start);
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
fail(`StartNode not found: ${newest.start}`);
}
return {
startHash: newest.start,
start: startNode.payload as StartNodePayload,
stepsNewestFirst,
headIsStart: false,
};
}
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
const node = uwf.store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
}
/**
* Recursively expand all cas_ref fields in a CAS node's payload,
* replacing hash strings with the referenced node's expanded payload.
*/
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
const seen = visited ?? new Set<string>();
if (seen.has(hash)) return hash; // cycle guard
seen.add(hash);
const node = store.get(hash);
if (node === null) return hash;
const schema = getSchema(store, node.type);
if (schema === null) return node.payload;
return expandValue(store, schema, node.payload, seen);
}
function expandValue(
store: CasStore,
schema: JSONSchema,
value: unknown,
visited: Set<string>,
): unknown {
// If this field is a cas_ref, expand it
if (schema.format === "cas_ref") {
if (typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
return value;
}
// anyOf (nullable refs)
if (Array.isArray(schema.anyOf)) {
for (const sub of schema.anyOf as JSONSchema[]) {
if (sub.format === "cas_ref" && typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
}
return value;
}
// Array of cas_ref items
if (schema.type === "array" && schema.items && Array.isArray(value)) {
const itemSchema = schema.items as JSONSchema;
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
}
// Object with properties
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
const props = schema.properties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
const propSchema = props[key];
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
}
return result;
}
return value;
}
function collectOrderedSteps(
uwf: UwfStore,
headHash: CasRef,
chain: ChainState,
): OrderedStepItem[] {
let hash: CasRef | null = headHash;
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null || node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
hashToNode.set(hash, { payload, timestamp: node.timestamp });
hash = payload.prev;
}
let cur: CasRef | null = chain.headIsStart ? null : headHash;
const ordered: OrderedStepItem[] = [];
while (cur !== null) {
const entry = hashToNode.get(cur);
if (entry === undefined) {
break;
}
ordered.push({ hash: cur, ...entry });
cur = entry.payload.prev;
}
ordered.reverse();
return ordered;
}
function formatYaml(value: unknown): string {
return stringify(value).trimEnd();
}
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
return [
`## Step ${index}: ${item.payload.role}`,
"",
`- **Hash:** \`${item.hash}\``,
`- **Agent:** ${item.payload.agent}`,
"",
"### Output",
"",
"```yaml",
outputYaml,
"```",
].join("\n");
}
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
const detailNode = uwf.store.get(detailRef);
if (detailNode === null) {
return null;
}
const detail = detailNode.payload as Record<string, unknown>;
const turns = detail.turns;
if (!Array.isArray(turns) || turns.length === 0) {
return null;
}
for (let i = turns.length - 1; i >= 0; i--) {
const turnRef = turns[i];
if (typeof turnRef !== "string") {
continue;
}
const turnNode = uwf.store.get(turnRef as CasRef);
if (turnNode === null) {
continue;
}
const turn = turnNode.payload as Record<string, unknown>;
if (
turn.role === "assistant" &&
typeof turn.content === "string" &&
turn.content.trim() !== ""
) {
return turn.content;
}
}
return null;
}
function formatThreadReadMarkdown(options: {
threadId: ThreadId;
workflowName: string;
workflowHash: CasRef;
prompt: string;
ordered: OrderedStepItem[];
uwf: UwfStore;
workflow: WorkflowPayload;
quota: number;
before: CasRef | null;
showStart: boolean;
}): string {
const { ordered, uwf, workflow, quota, before, showStart } = options;
// Determine which steps to consider
let candidates = ordered;
if (before !== null) {
const idx = candidates.findIndex((s) => s.hash === before);
if (idx === -1) {
fail(`step ${before} not found in thread ${options.threadId}`);
}
candidates = candidates.slice(0, idx);
}
// Walk backward from newest, accumulating chars until quota exceeded
const selected: OrderedStepItem[] = [];
let totalChars = 0;
for (let i = candidates.length - 1; i >= 0; i--) {
const item = candidates[i];
if (item === undefined) continue;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
selected.unshift(item);
totalChars += blockLen;
if (totalChars > quota) break;
}
const skippedCount = candidates.length - selected.length;
const parts: string[] = [];
// Start section
if (before === null || showStart) {
parts.push(
[
`# Thread \`${options.threadId}\``,
"",
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
"",
"## Task",
"",
options.prompt,
].join("\n"),
);
}
// Skip hint
if (skippedCount > 0 && selected.length > 0) {
const firstSelected = selected[0];
if (firstSelected !== undefined) {
parts.push(
`*(${skippedCount} earlier step${skippedCount > 1 ? "s" : ""}, load with \`uwf thread read ${options.threadId} --before ${firstSelected.hash}\`)*`,
);
}
}
// Step blocks
const startIndex = candidates.length - selected.length;
for (let i = 0; i < selected.length; i++) {
const item = selected[i];
if (item === undefined) continue;
const stepNum = startIndex + i + 1;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const ts = new Date(item.timestamp)
.toISOString()
.replace("T", " ")
.replace(/\.\d+Z$/, "");
const stepLines = [
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
];
const roleDef = workflow.roles[item.payload.role];
if (roleDef) {
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
}
if (item.payload.detail) {
const content = extractLastAssistantContent(uwf, item.payload.detail);
if (content !== null) {
stepLines.push("", "### Content", "", content);
}
}
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
parts.push(stepLines.join("\n"));
}
return parts.join("\n\n---\n\n");
}
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
const chronological = [...chain.stepsNewestFirst].reverse();
const steps: StepContext[] = chronological.map((step) => ({
role: step.role,
output: expandOutput(uwf, step.output),
detail: step.detail,
agent: step.agent,
}));
return { start: chain.start, steps };
}
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
const node = uwf.store.get(workflowRef);
if (node === null) {
fail(`workflow CAS node not found: ${workflowRef}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${workflowRef} is not a Workflow`);
}
return node.payload as WorkflowPayload;
}
function parseAgentOverride(override: string): AgentConfig {
const parts = override
.trim()
.split(/\s+/)
.filter((p) => p.length > 0);
const command = parts[0];
if (command === undefined) {
fail("agent override must not be empty");
}
return { command, args: parts.slice(1) };
}
function resolveAgentConfig(
config: WorkflowConfig,
workflow: WorkflowPayload,
role: string,
agentOverride: string | null,
): AgentConfig {
if (agentOverride !== null) {
return parseAgentOverride(agentOverride);
}
let alias: AgentAlias = config.defaultAgent;
if (config.agentOverrides !== null) {
const roleOverrides = config.agentOverrides[workflow.name];
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
alias = roleOverrides[role];
}
}
const agentConfig = config.agents[alias];
if (agentConfig === undefined) {
fail(`unknown agent alias in config: ${alias}`);
}
return agentConfig;
}
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
const argv = [...agent.args, threadId, role];
let stdout: string;
try {
stdout = execFileSync(agent.command, argv, {
encoding: "utf8",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
const stderr =
err.stderr === undefined
? ""
: typeof err.stderr === "string"
? err.stderr
: err.stderr.toString("utf8");
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
fail(`agent command failed (${agent.command})${detail}`);
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
async function archiveThread(
storageRoot: string,
threadId: ThreadId,
workflow: CasRef,
head: CasRef,
): Promise<void> {
const index = await loadThreadsIndex(storageRoot);
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
});
}
export async function cmdThreadStep(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflowHash = chain.start.workflow;
const workflow = loadWorkflowPayload(uwf, workflowHash);
const context = buildModeratorContext(uwf, chain);
const nextResult = await evaluate(workflow, context);
if (!nextResult.ok) {
fail(nextResult.error.message);
}
if (nextResult.value === END_ROLE) {
await archiveThread(storageRoot, threadId, workflowHash, headHash);
return {
workflow: workflowHash,
thread: threadId,
head: headHash,
done: true,
};
}
const role = nextResult.value;
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
loadDotenv({ path: getEnvPath(storageRoot) });
const newHead = spawnAgent(agent, threadId, role);
// Re-create store to pick up nodes written by the agent subprocess
const uwfAfter = await createUwfStore(storageRoot);
const newNode = uwfAfter.store.get(newHead);
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
fail(`agent returned hash that is not a StepNode: ${newHead}`);
}
// Reload threads index to avoid overwriting changes made by the agent subprocess
const freshIndex = await loadThreadsIndex(storageRoot);
freshIndex[threadId] = newHead;
await saveThreadsIndex(storageRoot, freshIndex);
const chainAfter = walkChain(uwfAfter, newHead);
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
const afterResult = await evaluate(workflow, contextAfter);
if (!afterResult.ok) {
fail(afterResult.error.message);
}
const done = afterResult.value === END_ROLE;
if (done) {
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
done,
};
}
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
return activeHead;
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return hist.head;
}
fail(`thread not found: ${threadId}`);
}
export async function cmdThreadSteps(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadStepsOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const startNode = uwf.store.get(chain.startHash);
if (startNode === null) {
fail(`StartNode not found: ${chain.startHash}`);
}
const startEntry: StartEntry = {
hash: chain.startHash,
workflow: chain.start.workflow,
prompt: chain.start.prompt,
timestamp: startNode.timestamp,
};
const stepEntries: StepEntry[] = [];
const ordered = collectOrderedSteps(uwf, headHash, chain);
for (const item of ordered) {
stepEntries.push({
hash: item.hash,
role: item.payload.role,
output: expandOutput(uwf, item.payload.output),
detail: item.payload.detail,
agent: item.payload.agent,
timestamp: item.timestamp,
});
}
return {
thread: threadId,
workflow: chain.start.workflow,
steps: [startEntry, ...stepEntries],
};
}
export async function cmdThreadRead(
storageRoot: string,
threadId: ThreadId,
quota: number = THREAD_READ_DEFAULT_QUOTA,
before: CasRef | null = null,
showStart: boolean = false,
): Promise<string> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflow = loadWorkflowPayload(uwf, chain.start.workflow);
const ordered = collectOrderedSteps(uwf, headHash, chain);
return formatThreadReadMarkdown({
threadId,
workflowName: workflow.name,
workflowHash: chain.start.workflow,
prompt: chain.start.prompt,
ordered,
uwf,
workflow,
quota,
before,
showStart,
});
}
export async function cmdThreadFork(
storageRoot: string,
stepHash: CasRef,
): Promise<ThreadForkOutput> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StartNode or StepNode`);
}
const newThreadId = generateUlid(Date.now()) as ThreadId;
const index = await loadThreadsIndex(storageRoot);
index[newThreadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
return {
thread: newThreadId,
forkedFrom: {
step: stepHash,
},
};
}
export async function cmdThreadStepDetails(
storageRoot: string,
stepHash: CasRef,
): Promise<unknown> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StepNode`);
}
const payload = node.payload as StepNodePayload;
if (!payload.detail) {
fail(`step ${stepHash} has no detail`);
}
return expandDeep(uwf.store, payload.detail);
}
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
fail(`failed to resolve workflow from head: ${head}`);
}
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
const historyEntry: ThreadHistoryLine = {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
};
await appendThreadHistory(storageRoot, historyEntry);
return { thread: threadId, archived: true };
}
+153
View File
@@ -0,0 +1,153 @@
import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
import { parse } from "yaml";
import {
createUwfStore,
findRegistryName,
loadWorkflowRegistry,
resolveWorkflowHash,
saveWorkflowRegistry,
type UwfStore,
} from "../store.js";
import { parseWorkflowPayload } from "../validate.js";
export type WorkflowListEntry = {
name: string;
hash: CasRef;
};
export type WorkflowPutOutput = {
name: string;
hash: CasRef;
};
export type WorkflowShowOutput = {
hash: CasRef;
name: string | null;
type: CasRef;
payload: WorkflowPayload;
timestamp: number;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function resolveOutputSchemaRef(
uwf: UwfStore,
roleName: string,
outputSchema: unknown,
): Promise<CasRef> {
if (!isJsonSchema(outputSchema)) {
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
}
const schema: JSONSchema = outputSchema.title === undefined
? { ...outputSchema, title: roleName }
: outputSchema;
return putSchema(uwf.store, schema);
}
async function materializeWorkflowPayload(
uwf: UwfStore,
raw: WorkflowPayload,
): Promise<WorkflowPayload> {
const roles: Record<string, RoleDefinition> = {};
for (const [roleName, role] of Object.entries(raw.roles)) {
const outputSchema = await resolveOutputSchemaRef(
uwf,
`${raw.name}.${roleName}`,
role.outputSchema,
);
roles[roleName] = {
description: role.description,
systemPrompt: role.systemPrompt,
outputSchema,
};
}
return {
name: raw.name,
description: raw.description,
roles,
conditions: raw.conditions,
graph: raw.graph,
};
}
export async function cmdWorkflowPut(
storageRoot: string,
filePath: string,
): Promise<WorkflowPutOutput> {
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch {
fail(`file not found: ${filePath}`);
}
let raw: unknown;
try {
raw = parse(text) as unknown;
} catch (e) {
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
const payload = parseWorkflowPayload(raw);
if (payload === null) {
fail("invalid workflow YAML: expected WorkflowPayload shape");
}
const uwf = await createUwfStore(storageRoot);
const materialized = await materializeWorkflowPayload(uwf, payload);
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
const node = uwf.store.get(hash);
if (node === null || !validate(uwf.store, node)) {
fail("stored workflow failed schema validation");
}
const registry = await loadWorkflowRegistry(storageRoot);
registry[materialized.name] = hash;
await saveWorkflowRegistry(storageRoot, registry);
return { name: materialized.name, hash };
}
export async function cmdWorkflowShow(
storageRoot: string,
id: string,
): Promise<WorkflowShowOutput> {
const uwf = await createUwfStore(storageRoot);
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, id);
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found: ${hash}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${hash} is not a Workflow (type ${node.type})`);
}
const payload = node.payload as WorkflowPayload;
return {
hash,
name: findRegistryName(registry, hash),
type: node.type,
payload,
timestamp: node.timestamp,
};
}
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
const registry = await loadWorkflowRegistry(storageRoot);
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
}
+12
View File
@@ -0,0 +1,12 @@
import { stringify } from "yaml";
export type OutputFormat = "json" | "yaml";
export function formatOutput(data: unknown, format: OutputFormat): string {
switch (format) {
case "json":
return JSON.stringify(data);
case "yaml":
return stringify(data).trimEnd();
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "@uncaged/uwf-protocol";
export type UwfSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
};
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every CLI invocation.
*/
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
]);
return { workflow, startNode, stepNode };
}
+212
View File
@@ -0,0 +1,212 @@
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
import { parse, stringify } from "yaml";
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
export type WorkflowRegistry = Record<string, CasRef>;
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
/**
* Resolve storage root.
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
*/
export function resolveStorageRoot(): string {
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultStorageRoot();
}
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
export function getRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflows.yaml");
}
export function getThreadsPath(storageRoot: string): string {
return join(storageRoot, "threads.yaml");
}
export function getHistoryPath(storageRoot: string): string {
return join(storageRoot, "history.jsonl");
}
export type ThreadHistoryLine = ThreadListItem & {
completedAt: number;
};
export type UwfStore = {
storageRoot: string;
store: Store;
schemas: UwfSchemaHashes;
};
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = getCasDir(storageRoot);
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
export async function loadWorkflowRegistry(storageRoot: string): Promise<WorkflowRegistry> {
const path = getRegistryPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const registry: WorkflowRegistry = {};
for (const [name, hash] of Object.entries(raw as Record<string, unknown>)) {
if (typeof hash === "string") {
registry[name] = hash;
}
}
return registry;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
export async function saveWorkflowRegistry(
storageRoot: string,
registry: WorkflowRegistry,
): Promise<void> {
const path = getRegistryPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const text = stringify(registry, { indent: 2 });
await writeFile(path, text, "utf8");
}
export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef {
return registry[id] !== undefined ? registry[id] : id;
}
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
for (const [name, h] of Object.entries(registry)) {
if (h === hash) {
return name;
}
}
return null;
}
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
const path = getThreadsPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
const path = getThreadsPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const text = stringify(index, { indent: 2 });
await writeFile(path, text, "utf8");
}
export async function loadThreadHistory(storageRoot: string): Promise<ThreadHistoryLine[]> {
const path = getHistoryPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const lines: ThreadHistoryLine[] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") {
continue;
}
let raw: unknown;
try {
raw = JSON.parse(trimmed) as unknown;
} catch {
continue;
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
continue;
}
const rec = raw as Record<string, unknown>;
const thread = rec.thread;
const workflow = rec.workflow;
const head = rec.head;
const completedAt = rec.completedAt;
if (
typeof thread === "string" &&
typeof workflow === "string" &&
typeof head === "string" &&
typeof completedAt === "number"
) {
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
}
}
return lines;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return [];
}
throw e;
}
}
export async function findThreadInHistory(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadHistoryLine | null> {
const history = await loadThreadHistory(storageRoot);
for (let i = history.length - 1; i >= 0; i--) {
const entry = history[i];
if (entry !== undefined && entry.thread === threadId) {
return entry;
}
}
return null;
}
export async function appendThreadHistory(
storageRoot: string,
entry: ThreadHistoryLine,
): Promise<void> {
const path = getHistoryPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const line = `${JSON.stringify(entry)}\n`;
await appendFile(path, line, "utf8");
}
+71
View File
@@ -0,0 +1,71 @@
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
export function isCasRef(value: string): value is CasRef {
return CAS_REF_PATTERN.test(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRoleDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const outputSchema = value.outputSchema;
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
return (
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
);
}
function isConditionDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return typeof value.description === "string" && typeof value.expression === "string";
}
function isTransition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const condition = value.condition;
return typeof value.role === "string" && (condition === null || typeof condition === "string");
}
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(itemCheck);
}
function isGraph(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
);
}
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isRecord(raw)) {
return null;
}
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
return null;
}
if (
!isStringRecord(raw.roles, isRoleDefinition) ||
!isStringRecord(raw.conditions, isConditionDefinition) ||
!isGraph(raw.graph)
) {
return null;
}
return raw as WorkflowPayload;
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../uwf-protocol" },
{ "path": "../uwf-moderator" },
{ "path": "../uwf-agent-kit" }
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
},
});
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
`${fixtureDescriptor}import fs from "node:fs";
export const run = async function* (input, options) {
fs.existsSync(".");
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
const h = await cas.put(input.prompt);
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
return { returnCode: 0, summary: "done" };
}
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
},
graph: { edges: [] },
};
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
const h = await cas.put( input.prompt);
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
return { returnCode: 0, summary: "ok" };
};
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
const dtsPath = join(bundleDir, "types.d.ts");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
const h = await cas.put( "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
const h = await cas.put( "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
const h = await cas.put( "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
const h = await cas.put( "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
expect(add1.ok).toBe(true);
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
const h = await cas.put( "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
const hash1 = add1.value.hash;
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
const h = await cas.put( "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
export const descriptor = {
const threeRoleBundleSource = `export const descriptor = {
description: "fork-cli",
roles: {
planner: { description: "planner", schema: {} },
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
const cas = options.cas;
const has = (r) => input.steps.some((s) => s.role === r);
if (!has("planner")) {
const h = await putContentMerkleNode(cas, "p1");
const h = await cas.put( "p1");
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
}
if (!has("coder")) {
const h = await putContentMerkleNode(cas, "c1");
const h = await cas.put( "c1");
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
}
if (!has("reviewer")) {
const body = "rev-" + String(input.steps.length);
const h = await putContentMerkleNode(cas, body);
const h = await cas.put( body);
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
}
return { returnCode: 0, summary: "done" };
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
const threadFixtureDescriptor = `export const descriptor = {
description: "thread-cli",
roles: {
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
`;
const fastBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const slowPlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 400));
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
await new Promise((r) => setTimeout(r, 10000));
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const pauseResumeBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "f");
let h = await cas.put( "f");
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
await new Promise((r) => setTimeout(r, 1500));
h = await putContentMerkleNode(cas, "s");
h = await cas.put( "s");
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
await new Promise((r) => setTimeout(r, 900));
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
@@ -51,7 +51,6 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
+1 -2
View File
@@ -249,8 +249,7 @@ Each role has:
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
## Development Workflow
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import {
computeDurationMs,
extractLastAssistantContent,
messageToTurnPayload,
parseSessionIdFromStdout,
storeHermesSessionDetail,
} from "../src/session-detail.js";
import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js";
describe("parseSessionIdFromStdout", () => {
test("reads session_id from the last non-empty line", () => {
const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
});
test("reads session_id from the first line (quiet mode)", () => {
const stdout = "session_id: 20260518_165315_3467a1\nHello world\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_165315_3467a1");
});
test("returns null when no session_id line present", () => {
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
});
});
describe("messageToTurnPayload", () => {
test("maps assistant tool_calls to toolCalls", () => {
const msg: HermesSessionMessage = {
role: "assistant",
content: "",
reasoning: null,
tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }],
};
const turn = messageToTurnPayload(msg, 0);
expect(turn).toEqual({
index: 0,
role: "assistant",
content: "",
toolCalls: [{ name: "read_file", args: '{"path":"x"}' }],
reasoning: null,
});
});
test("skips user messages", () => {
const msg: HermesSessionMessage = {
role: "user",
content: "hi",
reasoning: null,
tool_calls: null,
};
expect(messageToTurnPayload(msg, 0)).toBeNull();
});
});
describe("extractLastAssistantContent", () => {
test("returns the last non-empty assistant content", () => {
const messages: HermesSessionMessage[] = [
{ role: "assistant", content: "first", reasoning: null, tool_calls: null },
{ role: "tool", content: "tool output", reasoning: null, tool_calls: null },
{ role: "assistant", content: "", reasoning: null, tool_calls: null },
{ role: "assistant", content: "final answer", reasoning: null, tool_calls: null },
];
expect(extractLastAssistantContent(messages)).toBe("final answer");
});
});
describe("computeDurationMs", () => {
test("computes elapsed time from session_start", () => {
const now = Date.parse("2026-05-18T13:32:59.028640Z");
const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now);
expect(duration).toBe(60_000);
});
});
describe("storeHermesSessionDetail", () => {
test("stores hermes-detail root with cas_ref turns walkable", async () => {
const session: HermesSessionJson = {
session_id: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
session_start: "2026-05-18T13:31:59.028640",
messages: [
{ role: "user", content: "task", reasoning: null, tool_calls: null },
{
role: "assistant",
content: "",
reasoning: "thinking",
tool_calls: [{ function: { name: "terminal", arguments: "{}" } }],
},
{ role: "tool", content: "ok", reasoning: null, tool_calls: null },
{ role: "assistant", content: "done", reasoning: null, tool_calls: null },
],
};
const store = createMemoryStore();
const now = Date.parse("2026-05-18T13:32:59.028640");
const { detailHash, output } = await storeHermesSessionDetail(store, session, now);
expect(output).toBe("done");
const detailNode = store.get(detailHash);
expect(detailNode).not.toBeNull();
if (detailNode === null) {
return;
}
expect(validate(store, detailNode)).toBe(true);
expect(detailNode.payload).toMatchObject({
sessionId: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
duration: 60_000,
turnCount: 3,
});
const turnRefs = refs(store, detailNode);
expect(turnRefs).toHaveLength(3);
const visited: string[] = [];
walk(store, detailHash, (hash) => visited.push(hash));
expect(visited).toContain(detailHash);
for (const turnHash of turnRefs) {
expect(visited).toContain(turnHash);
}
});
});
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@uncaged/uwf-agent-hermes",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf-hermes": "./src/cli.ts"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/uwf-agent-kit": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bun
import { createHermesAgent } from "./hermes.js";
const main = createHermesAgent();
void main();
+117
View File
@@ -0,0 +1,117 @@
import { spawn } from "node:child_process";
import { type AgentContext, type AgentRunResult, createAgent } from "@uncaged/uwf-agent-kit";
import {
loadHermesSession,
parseSessionIdFromStdout,
storeHermesRawOutput,
storeHermesSessionDetail,
} from "./session-detail.js";
const HERMES_COMMAND = "hermes";
const HERMES_MAX_TURNS = 90;
function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) {
return "";
}
const lines: string[] = ["## Previous Steps"];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (step === undefined) {
continue;
}
lines.push("");
lines.push(`### Step ${i + 1}: ${step.role}`);
lines.push(`Output: ${JSON.stringify(step.output)}`);
lines.push(`Agent: ${step.agent}`);
}
return lines.join("\n");
}
/** Assemble system prompt, task, and prior step outputs for Hermes. */
export function buildHermesPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const systemPrompt = roleDef?.systemPrompt ?? "";
const parts: string[] = [];
if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
parts.push(systemPrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const args = [
"chat",
"-q",
prompt,
"--yolo",
"--max-turns",
String(HERMES_MAX_TURNS),
"--quiet",
];
const child = spawn(HERMES_COMMAND, args, {
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (cause) => {
const message = cause instanceof Error ? cause.message : String(cause);
reject(new Error(`hermes spawn failed: ${message}`));
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
reject(new Error(`hermes exited with code ${code ?? "null"}${detail}`));
});
});
}
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildHermesPrompt(ctx);
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
const { store } = ctx;
// --quiet mode: session_id may be on stdout or stderr
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
if (sessionId !== null) {
const session = await loadHermesSession(sessionId);
if (session !== null) {
const { detailHash, output } = await storeHermesSessionDetail(store, session);
return { output, detailHash };
}
}
const detailHash = await storeHermesRawOutput(store, stdout);
return { output: stdout, detailHash };
}
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
export function createHermesAgent(): () => Promise<void> {
return createAgent({
name: "hermes",
run: runHermes,
});
}
+1
View File
@@ -0,0 +1 @@
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";
+57
View File
@@ -0,0 +1,57 @@
import type { JSONSchema } from "@uncaged/json-cas";
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
};
export const HERMES_TURN_SCHEMA: JSONSchema = {
title: "hermes-turn",
type: "object",
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [{ type: "array", items: HERMES_TOOL_CALL_SCHEMA }, { type: "null" }],
},
reasoning: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
additionalProperties: false,
};
export const HERMES_DETAIL_SCHEMA: JSONSchema = {
title: "hermes-detail",
type: "object",
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" },
model: { type: "string" },
duration: { type: "integer" },
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
/** Fallback detail when Hermes session file is unavailable. */
export const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = {
title: "hermes-raw-output",
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
@@ -0,0 +1,223 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
import type {
HermesDetailPayload,
HermesSessionJson,
HermesSessionMessage,
HermesToolCall,
HermesTurnPayload,
HermesTurnRole,
} from "./types.js";
const SESSION_ID_LINE = /^session_id:\s*(\S+)\s*$/i;
export function getHermesSessionsDir(): string {
return join(homedir(), ".hermes", "sessions");
}
export function getHermesSessionPath(sessionId: string): string {
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
}
/** Parse `session_id: …` from any line of Hermes stdout. */
export function parseSessionIdFromStdout(stdout: string): string | null {
const lines = stdout.split(/\r?\n/);
for (const line of lines) {
const match = SESSION_ID_LINE.exec(line.trim());
if (match?.[1] !== undefined) {
return match[1];
}
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseToolCalls(raw: unknown): HermesSessionMessage["tool_calls"] {
if (!Array.isArray(raw) || raw.length === 0) {
return null;
}
const calls: NonNullable<HermesSessionMessage["tool_calls"]> = [];
for (const entry of raw) {
if (!isRecord(entry)) {
continue;
}
const fn = entry.function;
if (!isRecord(fn)) {
continue;
}
const name = fn.name;
const args = fn.arguments;
if (typeof name !== "string" || typeof args !== "string") {
continue;
}
calls.push({ function: { name, arguments: args } });
}
return calls.length > 0 ? calls : null;
}
function normalizeMessage(raw: unknown): HermesSessionMessage | null {
if (!isRecord(raw)) {
return null;
}
const role = raw.role;
if (role !== "assistant" && role !== "tool" && role !== "user") {
return null;
}
const content = typeof raw.content === "string" ? raw.content : raw.content === null ? null : "";
const reasoning =
typeof raw.reasoning === "string"
? raw.reasoning
: raw.reasoning === null || raw.reasoning === undefined
? null
: null;
const tool_calls = parseToolCalls(raw.tool_calls);
return { role, content, reasoning, tool_calls };
}
function parseSessionJson(raw: unknown): HermesSessionJson | null {
if (!isRecord(raw)) {
return null;
}
const session_id = raw.session_id;
const model = raw.model;
const session_start = raw.session_start;
const messagesRaw = raw.messages;
if (
typeof session_id !== "string" ||
typeof model !== "string" ||
typeof session_start !== "string" ||
!Array.isArray(messagesRaw)
) {
return null;
}
const messages: HermesSessionMessage[] = [];
for (const entry of messagesRaw) {
const msg = normalizeMessage(entry);
if (msg !== null) {
messages.push(msg);
}
}
return { session_id, model, session_start, messages };
}
export async function loadHermesSession(sessionId: string): Promise<HermesSessionJson | null> {
const path = getHermesSessionPath(sessionId);
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
return parseSessionJson(raw);
} catch {
return null;
}
}
export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number {
const startMs = Date.parse(sessionStart);
if (Number.isNaN(startMs)) {
return 0;
}
return Math.max(0, nowMs - startMs);
}
function mapSessionToolCalls(
toolCalls: HermesSessionMessage["tool_calls"],
): HermesToolCall[] | null {
if (toolCalls === null || toolCalls.length === 0) {
return null;
}
return toolCalls.map((call) => ({
name: call.function.name,
args: call.function.arguments,
}));
}
export function messageToTurnPayload(
message: HermesSessionMessage,
index: number,
): HermesTurnPayload | null {
if (message.role !== "assistant" && message.role !== "tool") {
return null;
}
const role = message.role as HermesTurnRole;
return {
index,
role,
content: message.content ?? "",
toolCalls: mapSessionToolCalls(message.tool_calls),
reasoning: message.reasoning,
};
}
/** Last assistant message with non-empty text content (walks backward). */
export function extractLastAssistantContent(messages: HermesSessionMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg === undefined) {
continue;
}
if (msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") {
return msg.content;
}
}
return "";
}
type HermesSchemaHashes = {
turn: string;
detail: string;
rawOutput: string;
};
async function registerHermesSchemas(store: Store): Promise<HermesSchemaHashes> {
await bootstrap(store);
const [turn, detail, rawOutput] = await Promise.all([
putSchema(store, HERMES_TURN_SCHEMA),
putSchema(store, HERMES_DETAIL_SCHEMA),
putSchema(store, HERMES_RAW_OUTPUT_SCHEMA),
]);
return { turn, detail, rawOutput };
}
export async function storeHermesSessionDetail(
store: Store,
session: HermesSessionJson,
nowMs: number = Date.now(),
): Promise<{ detailHash: string; output: string }> {
const schemas = await registerHermesSchemas(store);
const turnHashes: string[] = [];
let turnIndex = 0;
for (const message of session.messages) {
const turn = messageToTurnPayload(message, turnIndex);
if (turn === null) {
continue;
}
const hash = await store.put(schemas.turn, turn);
turnHashes.push(hash);
turnIndex += 1;
}
const detail: HermesDetailPayload = {
sessionId: session.session_id,
model: session.model,
duration: computeDurationMs(session.session_start, nowMs),
turnCount: turnHashes.length,
turns: turnHashes,
};
const detailHash = await store.put(schemas.detail, detail);
const output = extractLastAssistantContent(session.messages);
return { detailHash, output };
}
export async function storeHermesRawOutput(store: Store, rawOutput: string): Promise<string> {
const schemas = await registerHermesSchemas(store);
return store.put(schemas.rawOutput, { text: rawOutput });
}
+43
View File
@@ -0,0 +1,43 @@
export type HermesTurnRole = "assistant" | "tool";
export type HermesToolCall = {
name: string;
args: string;
};
export type HermesTurnPayload = {
index: number;
role: HermesTurnRole;
content: string;
toolCalls: HermesToolCall[] | null;
reasoning: string | null;
};
export type HermesDetailPayload = {
sessionId: string;
model: string;
duration: number;
turnCount: number;
turns: string[];
};
export type HermesSessionToolCall = {
function: {
name: string;
arguments: string;
};
};
export type HermesSessionMessage = {
role: string;
content: string | null;
tool_calls: HermesSessionToolCall[] | null;
reasoning: string | null;
};
export type HermesSessionJson = {
session_id: string;
model: string;
session_start: string;
messages: HermesSessionMessage[];
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-agent-kit" }]
}
@@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
describe("buildOutputFormatInstruction", () => {
test("always includes the frontmatter example block", () => {
const result = buildOutputFormatInstruction({});
expect(result).toContain("---");
expect(result).toContain("status: done");
expect(result).toContain("confidence:");
expect(result).toContain("scope: role");
});
test("always marks frontmatter as the primary deliverable", () => {
const result = buildOutputFormatInstruction({});
expect(result).toContain("primary deliverable");
});
test("lists fields from a flat object schema", () => {
const schema = {
type: "object",
properties: {
status: { type: "string" },
confidence: { type: "number" },
},
};
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("`status`");
expect(result).toContain("`confidence`");
});
test("lists union of fields from an anyOf schema", () => {
const schema = {
anyOf: [
{
type: "object",
properties: { alpha: { type: "string" } },
},
{
type: "object",
properties: { beta: { type: "number" } },
},
],
};
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("`alpha`");
expect(result).toContain("`beta`");
});
test("lists union of fields from a oneOf schema", () => {
const schema = {
oneOf: [
{
type: "object",
properties: { foo: { type: "string" } },
},
{
type: "object",
properties: { bar: { type: "boolean" } },
},
],
};
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("`foo`");
expect(result).toContain("`bar`");
});
test("falls back gracefully for a non-object schema with no properties", () => {
const result = buildOutputFormatInstruction({ type: "string" });
expect(result).toContain("schema fields will be extracted automatically");
});
test("does not list a field more than once for a union with overlapping keys", () => {
const schema = {
anyOf: [
{ type: "object", properties: { shared: { type: "string" } } },
{ type: "object", properties: { shared: { type: "number" } } },
],
};
const result = buildOutputFormatInstruction(schema);
const matches = [...result.matchAll(/`shared`/g)];
expect(matches.length).toBe(1);
});
test("includes focus reminder about role scope", () => {
const result = buildOutputFormatInstruction({});
expect(result).toContain("Focus exclusively on YOUR role");
});
});
@@ -0,0 +1,136 @@
import { describe, expect, test } from "vitest";
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
/** JSON Schema that exactly matches the AgentFrontmatter fields. */
const FRONTMATTER_SCHEMA = {
type: "object",
properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] },
next: { anyOf: [{ type: "string" }, { type: "null" }] },
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
artifacts: { type: "array", items: { type: "string" } },
scope: { type: "string" },
},
required: ["status", "next", "confidence", "artifacts", "scope"],
additionalProperties: false,
};
/** JSON Schema that requires a non-frontmatter field — fast path must not satisfy it. */
const STRICT_SCHEMA = {
type: "object",
properties: {
requiredField: { type: "string" },
},
required: ["requiredField"],
additionalProperties: false,
};
async function makeStoreWithSchema(schema: Record<string, unknown>) {
const store = createMemoryStore();
const schemaHash = await putSchema(store, schema);
return { store, schemaHash };
}
// ── Happy path ─────────────────────────────────────────────────────────────────
describe("tryFrontmatterFastPath — happy path", () => {
test("parses valid frontmatter and returns outputHash + stripped body", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = [
"---",
"status: done",
"next: reviewer",
"confidence: 0.9",
"artifacts: [src/foo.ts]",
"scope: role",
"---",
"",
"## Summary",
"Work is complete.",
].join("\n");
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result?.body).toContain("## Summary");
expect(result?.body).toContain("Work is complete.");
expect(result?.body).not.toContain("status: done");
expect(typeof result?.outputHash).toBe("string");
expect((result?.outputHash ?? "").length).toBeGreaterThan(0);
});
test("stored CAS node payload matches frontmatter fields", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
const node = store.get(result!.outputHash);
expect(node).not.toBeNull();
const payload = node!.payload as Record<string, unknown>;
expect(payload.status).toBe("done");
expect(payload.next).toBeNull();
expect(payload.confidence).toBeNull();
expect(payload.artifacts).toEqual([]);
expect(payload.scope).toBe("role");
});
});
// ── Fallback: no frontmatter ───────────────────────────────────────────────────
describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
test("returns null for plain markdown without frontmatter block", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const result = await tryFrontmatterFastPath(
"This is plain markdown without any frontmatter.",
schemaHash,
store,
);
expect(result).toBeNull();
});
});
// ── Fallback: invalid frontmatter ─────────────────────────────────────────────
describe("tryFrontmatterFastPath — fallback: invalid frontmatter", () => {
test("returns null when confidence is out of range [0, 1]", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
});
test("returns null when next contains whitespace", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nnext: some role\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
});
});
// ── Fallback: schema mismatch ─────────────────────────────────────────────────
describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
test("returns null when outputSchema requires fields not in frontmatter", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA);
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
});
});
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import type { WorkflowConfig } from "@uncaged/uwf-protocol";
import { resolveExtractModelAlias } from "../src/extract.js";
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
return {
providers: {},
models: {
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
"gpt4o-mini": { provider: "openai", name: "gpt-4o-mini" },
},
agents: {},
defaultAgent: "hermes",
agentOverrides: null,
defaultModel: "sonnet",
modelOverrides: null,
...overrides,
};
}
describe("resolveExtractModelAlias", () => {
test("uses modelOverrides.extract when set", () => {
const config = baseConfig({
modelOverrides: { extract: "gpt4o-mini" },
});
expect(resolveExtractModelAlias(config)).toBe("gpt4o-mini");
});
test("falls back to models.extract alias when present", () => {
const config = baseConfig({
models: {
extract: { provider: "openai", name: "gpt-4o-mini" },
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
},
});
expect(resolveExtractModelAlias(config)).toBe("extract");
});
test("falls back to defaultModel", () => {
expect(resolveExtractModelAlias(baseConfig())).toBe("sonnet");
});
});
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@uncaged/uwf-agent-kit",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@uncaged/uwf-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"dotenv": "^16.6.1",
"yaml": "^2.8.4"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,75 @@
import type { JSONSchema } from "@uncaged/json-cas";
/**
* Extract top-level property names from a JSON Schema object.
*
* Handles:
* - Object schemas with a `properties` key
* - Union schemas via `anyOf` / `oneOf` — union of all variant property names
*
* Returns an empty array for schemas with no inspectable property definitions.
*/
function extractSchemaFields(schema: JSONSchema): string[] {
if (typeof schema.properties === "object" && schema.properties !== null) {
return Object.keys(schema.properties as Record<string, unknown>);
}
const unionKey = Array.isArray(schema.anyOf)
? "anyOf"
: Array.isArray(schema.oneOf)
? "oneOf"
: null;
if (unionKey !== null) {
const variants = schema[unionKey] as JSONSchema[];
const fieldSet = new Set<string>();
for (const variant of variants) {
for (const field of extractSchemaFields(variant)) {
fieldSet.add(field);
}
}
return [...fieldSet];
}
return [];
}
/**
* Build a concise output format instruction block for an agent role.
*
* The instruction describes the expected frontmatter markdown format and lists
* the meta fields derived from the JSON Schema. It is prepended to the agent's
* system prompt so the deliverable format is the first thing the agent sees.
*/
export function buildOutputFormatInstruction(schema: JSONSchema): string {
const fields = extractSchemaFields(schema);
const fieldList =
fields.length > 0
? fields.map((f) => ` - \`${f}\``).join("\n")
: " (schema fields will be extracted automatically)";
return `## Deliverable Format
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
\`\`\`
---
status: done # done | needs_input | in_progress | failed
next: <role-name> # suggested next role, or omit
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
artifacts: # list of file paths or CAS hashes you produced
- path/to/file.ts
scope: role # role | thread
---
... your markdown work here ...
\`\`\`
The frontmatter is the **primary deliverable** — the engine reads it directly.
Your meta output must satisfy these fields:
${fieldList}
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
}
+203
View File
@@ -0,0 +1,203 @@
import type { Store } from "@uncaged/json-cas";
import type {
CasRef,
StartNodePayload,
StepContext,
StepNodePayload,
ThreadId,
} from "@uncaged/uwf-protocol";
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
import type { AgentStore } from "./storage.js";
import type { AgentContext } from "./types.js";
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
function fail(message: string): never {
throw new Error(message);
}
function walkChain(
store: Store,
schemas: AgentStore["schemas"],
headHash: CasRef,
): ChainState {
const headNode = store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
}
if (headNode.type === schemas.startNode) {
return {
startHash: headHash,
start: headNode.payload as StartNodePayload,
stepsNewestFirst: [],
headIsStart: true,
};
}
if (headNode.type !== schemas.stepNode) {
fail(`head ${headHash} is not a StartNode or StepNode`);
}
const stepsNewestFirst: StepNodePayload[] = [];
let hash: CasRef | null = headHash;
while (hash !== null) {
const node = store.get(hash);
if (node === null) {
fail(`CAS node not found while walking chain: ${hash}`);
}
if (node.type !== schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
stepsNewestFirst.push(payload);
hash = payload.prev;
}
const newest = stepsNewestFirst[0];
if (newest === undefined) {
fail(`empty step chain at head ${headHash}`);
}
const startNode = store.get(newest.start);
if (startNode === null || startNode.type !== schemas.startNode) {
fail(`StartNode not found: ${newest.start}`);
}
return {
startHash: newest.start,
start: startNode.payload as StartNodePayload,
stepsNewestFirst,
headIsStart: false,
};
}
function expandOutput(
store: Store,
outputRef: CasRef,
): unknown {
const node = store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
}
async function buildHistory(
store: Store,
stepsNewestFirst: StepNodePayload[],
): Promise<StepContext[]> {
const chronological = [...stepsNewestFirst].reverse();
const history: StepContext[] = [];
for (const step of chronological) {
history.push({
role: step.role,
output: expandOutput(store, step.output),
detail: step.detail,
agent: step.agent,
});
}
return history;
}
async function loadWorkflow(
store: Store,
schemas: AgentStore["schemas"],
workflowRef: CasRef,
) {
const node = store.get(workflowRef);
if (node === null) {
fail(`workflow CAS node not found: ${workflowRef}`);
}
if (node.type !== schemas.workflow) {
fail(`node ${workflowRef} is not a Workflow`);
}
return node.payload as AgentContext["workflow"];
}
/**
* Build agent execution context from thread head in threads.yaml.
* Walks the CAS chain from head to StartNode and expands step outputs.
*/
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
const storageRoot = resolveStorageRoot();
const agentStore = await createAgentStore(storageRoot);
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
const chain = walkChain(store, schemas, headHash);
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
const roleDef = workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
return {
threadId,
role,
start: chain.start,
steps,
workflow,
store,
outputFormatInstruction: "",
};
}
export type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
headHash: CasRef;
chain: ChainState;
};
/**
* Same as {@link buildContext} but also returns chain metadata for writing the next StepNode.
*/
export async function buildContextWithMeta(
threadId: ThreadId,
role: string,
): Promise<AgentContext & { meta: BuildContextMeta }> {
const storageRoot = resolveStorageRoot();
const agentStore = await createAgentStore(storageRoot);
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
const chain = walkChain(store, schemas, headHash);
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
const roleDef = workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
return {
threadId,
role,
start: chain.start,
steps,
workflow,
store,
outputFormatInstruction: "",
meta: { storageRoot, store, schemas, headHash, chain },
};
}
+181
View File
@@ -0,0 +1,181 @@
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/uwf-protocol";
import { config as loadDotenv } from "dotenv";
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
export type ResolvedLlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Resolve model alias for extract: modelOverrides.extract → models.extract → defaultModel. */
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
const fromOverride = config.modelOverrides?.extract ?? null;
if (fromOverride !== null) {
return fromOverride;
}
if (config.models.extract !== undefined) {
return "extract";
}
if (config.models.default !== undefined) {
return "default";
}
return config.defaultModel;
}
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
if (modelEntry === undefined) {
throw new Error(`unknown model alias: ${alias}`);
}
const providerEntry = config.providers[modelEntry.provider];
if (providerEntry === undefined) {
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
}
const apiKey = process.env[providerEntry.apiKeyEnv];
if (apiKey === undefined || apiKey === "") {
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
}
return {
baseUrl: providerEntry.baseUrl,
apiKey,
model: modelEntry.name,
};
}
function chatUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
function extractJsonFromAssistantText(text: string): unknown {
const trimmed = text.trim();
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
const candidate = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
return JSON.parse(candidate) as unknown;
}
function parseAssistantText(parsed: unknown): string {
if (!isRecord(parsed)) {
throw new Error("LLM response is not an object");
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new Error("LLM response has no choices");
}
const c0 = choices[0];
if (!isRecord(c0)) {
throw new Error("LLM choice is not an object");
}
const messageObj = c0.message;
if (!isRecord(messageObj)) {
throw new Error("LLM message is not an object");
}
const content = messageObj.content;
if (typeof content !== "string") {
throw new Error("LLM message has no text content");
}
return content;
}
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
): Promise<string> {
let response: Response;
try {
response = await fetch(chatUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages,
response_format: { type: "json_object" },
}),
});
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM network error: ${message}`);
}
const responseText = await response.text();
if (!response.ok) {
throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM invalid JSON response: ${message}`);
}
return parseAssistantText(parsed);
}
export type ExtractResult = {
value: unknown;
hash: CasRef;
};
/**
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
* Loads config.yaml and .env from the workflow storage root.
*/
export async function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult> {
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const { store } = await createAgentStore(storageRoot);
const schema = getSchema(store, outputSchema);
if (schema === null) {
throw new Error(`output schema not found in CAS: ${outputSchema}`);
}
const modelAlias = resolveExtractModelAlias(config);
const provider = resolveModel(config, modelAlias);
const schemaText = JSON.stringify(schema, null, 2);
const assistantText = await chatCompletionText(provider, [
{
role: "system",
content:
"Extract structured data from the agent output. Reply with a single JSON object only, no markdown or prose. The JSON must validate against this JSON Schema:\n" +
schemaText,
},
{
role: "user",
content: rawOutput,
},
]);
let structured: unknown;
try {
structured = extractJsonFromAssistantText(assistantText);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`failed to parse extracted JSON: ${message}`);
}
const outputHash = await store.put(outputSchema, structured);
const node = store.get(outputHash);
if (node === null || !validate(store, node)) {
throw new Error("extracted output failed JSON Schema validation");
}
return { value: structured, hash: outputHash };
}
+61
View File
@@ -0,0 +1,61 @@
import { validate } from "@uncaged/json-cas";
import type { Store } from "@uncaged/json-cas";
import type { CasRef } from "@uncaged/uwf-protocol";
import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util";
export type FrontmatterFastPathResult = {
body: string;
outputHash: CasRef;
};
/**
* Try to satisfy `outputSchema` from frontmatter fields alone.
*
* Returns a result containing the stored CAS hash and stripped body on success,
* or `null` when frontmatter is absent, invalid, or does not satisfy the schema.
* Never throws.
*
* The candidate object is put into the real CAS store (idempotent content-addressed
* write) and validated against the output schema. If validation fails the node
* is orphaned — it will be GC'd on the next collection pass.
*/
export async function tryFrontmatterFastPath(
raw: string,
outputSchema: CasRef,
store: Store,
): Promise<FrontmatterFastPathResult | null> {
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
if (frontmatter === null) {
return null;
}
const validationErrors = validateFrontmatter(frontmatter);
if (validationErrors.length > 0) {
return null;
}
const candidate: Record<string, unknown> = {
status: frontmatter.status,
next: frontmatter.next,
confidence: frontmatter.confidence,
artifacts: [...frontmatter.artifacts],
scope: frontmatter.scope,
};
let outputHash: CasRef;
let node: ReturnType<Store["get"]>;
try {
outputHash = await store.put(outputSchema, candidate);
node = store.get(outputHash);
} catch {
return null;
}
if (node === null || !validate(store, node)) {
return null;
}
return { body, outputHash };
}
+14
View File
@@ -0,0 +1,14 @@
export type { BuildContextMeta } from "./context.js";
export { buildContext, buildContextWithMeta } from "./context.js";
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
export {
extract,
resolveExtractModelAlias,
resolveModel,
} from "./extract.js";
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
+150
View File
@@ -0,0 +1,150 @@
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
import { config as loadDotenv } from "dotenv";
import { buildContextWithMeta } from "./context.js";
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
import { extract } from "./extract.js";
import { tryFrontmatterFastPath } from "./frontmatter.js";
import type { AgentStore } from "./storage.js";
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function agentLabel(name: string): string {
if (name.startsWith("uwf-")) {
return name;
}
return `uwf-${name}`;
}
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
const threadId = argv[2];
const role = argv[3];
if (threadId === undefined || threadId === "") {
fail("usage: <agent-cli> <thread-id> <role>");
}
if (role === undefined || role === "") {
fail("usage: <agent-cli> <thread-id> <role>");
}
return { threadId: threadId as ThreadId, role };
}
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
return fn().catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
fail(`${label}: ${message}`);
});
}
async function writeStepNode(options: {
store: AgentStore["store"];
schemas: AgentStore["schemas"];
startHash: CasRef;
prevHash: CasRef | null;
role: string;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
prev: options.prevHash,
role: options.role,
output: options.outputHash,
detail: options.detailHash,
agent: options.agentName,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
if (node === null || !validate(options.store, node)) {
fail("stored StepNode failed schema validation");
}
return hash;
}
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
return runWithMessage("agent run failed", () => options.run(ctx));
}
async function extractOutput(
rawOutput: string,
outputSchema: CasRef,
storageRoot: string,
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
): Promise<CasRef> {
const fastPath = await runWithMessage("frontmatter fast path", () =>
tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store),
).catch(() => null);
if (fastPath !== null) {
return fastPath.outputHash;
}
const config = await runWithMessage("failed to load config", () =>
loadWorkflowConfig(storageRoot),
);
const extracted = await runWithMessage("extract failed", () =>
extract(rawOutput, outputSchema, config),
);
return extracted.hash;
}
async function persistStep(options: {
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
return writeStepNode({
store,
schemas,
startHash: chain.startHash,
prevHash: chain.headIsStart ? null : headHash,
role: options.ctx.role,
outputHash: options.outputHash,
detailHash: options.detailHash,
agentName: options.agentName,
});
}
/**
* Create an agent CLI entrypoint.
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
* writes StepNode to CAS, and prints the new node hash to stdout.
*/
export function createAgent(options: AgentOptions): () => Promise<void> {
return async function main(): Promise<void> {
const { threadId, role } = parseArgv(process.argv);
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role));
const roleDef = ctx.workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role: ${role}`);
}
const outputSchema = getSchema(ctx.meta.store, roleDef.outputSchema);
if (outputSchema !== null) {
ctx.outputFormatInstruction = buildOutputFormatInstruction(outputSchema);
}
const agentResult = await runAgent(options, ctx);
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot, ctx);
const stepHash = await persistStep({
ctx,
outputHash,
detailHash: agentResult.detailHash,
agentName: agentLabel(options.name),
});
process.stdout.write(`${stepHash}\n`);
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/uwf-protocol";
export type UwfAgentSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
};
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every agent invocation.
*/
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
]);
return { workflow, startNode, stepNode };
}
+227
View File
@@ -0,0 +1,227 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type {
AgentAlias,
AgentConfig,
ModelAlias,
ModelConfig,
ProviderAlias,
ProviderConfig,
Scenario,
ThreadId,
ThreadsIndex,
WorkflowConfig,
WorkflowName,
} from "@uncaged/uwf-protocol";
import { parse } from "yaml";
import { registerAgentSchemas } from "./schemas.js";
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
/**
* Resolve storage root.
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
*/
export function resolveStorageRoot(): string {
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultStorageRoot();
}
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
export function getConfigPath(storageRoot: string): string {
return join(storageRoot, "config.yaml");
}
export function getEnvPath(storageRoot: string): string {
return join(storageRoot, ".env");
}
export function getThreadsPath(storageRoot: string): string {
return join(storageRoot, "threads.yaml");
}
export type AgentStore = {
storageRoot: string;
store: Store;
schemas: Awaited<ReturnType<typeof registerAgentSchemas>>;
};
export async function createAgentStore(storageRoot: string): Promise<AgentStore> {
const store = createFsStore(getCasDir(storageRoot));
const schemas = await registerAgentSchemas(store);
return { storageRoot, store, schemas };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig> {
if (!isRecord(raw)) {
throw new Error("config.providers must be a mapping");
}
const providers: Record<ProviderAlias, ProviderConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.providers.${name} must be a mapping`);
}
const baseUrl = entry.baseUrl;
const apiKeyEnv = entry.apiKeyEnv;
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
}
providers[name] = { baseUrl, apiKeyEnv };
}
return providers;
}
function normalizeModels(raw: unknown): Record<ModelAlias, ModelConfig> {
if (!isRecord(raw)) {
throw new Error("config.models must be a mapping");
}
const models: Record<ModelAlias, ModelConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.models.${name} must be a mapping`);
}
const provider = entry.provider;
const modelName = entry.name;
if (typeof provider !== "string" || typeof modelName !== "string") {
throw new Error(`config.models.${name} requires provider and name`);
}
models[name] = { provider, name: modelName };
}
return models;
}
function normalizeAgents(raw: unknown): Record<AgentAlias, AgentConfig> {
if (!isRecord(raw)) {
throw new Error("config.agents must be a mapping");
}
const agents: Record<AgentAlias, AgentConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.agents.${name} must be a mapping`);
}
const command = entry.command;
const argsRaw = entry.args;
if (typeof command !== "string") {
throw new Error(`config.agents.${name} requires command`);
}
const args = Array.isArray(argsRaw)
? argsRaw.filter((a): a is string => typeof a === "string")
: [];
agents[name] = { command, args };
}
return agents;
}
function normalizeModelOverrides(raw: unknown): Record<Scenario, ModelAlias> | null {
if (raw === undefined || raw === null) {
return null;
}
if (!isRecord(raw)) {
throw new Error("config.modelOverrides must be a mapping or null");
}
const overrides: Record<Scenario, ModelAlias> = {};
for (const [scene, alias] of Object.entries(raw)) {
if (typeof alias === "string") {
overrides[scene] = alias;
}
}
return overrides;
}
function normalizeAgentOverrides(
raw: unknown,
): Record<WorkflowName, Record<string, AgentAlias>> | null {
if (raw === undefined || raw === null) {
return null;
}
if (!isRecord(raw)) {
throw new Error("config.agentOverrides must be a mapping or null");
}
const overrides: Record<WorkflowName, Record<string, AgentAlias>> = {};
for (const [workflowName, rolesRaw] of Object.entries(raw)) {
if (!isRecord(rolesRaw)) {
continue;
}
const roles: Record<string, AgentAlias> = {};
for (const [roleName, alias] of Object.entries(rolesRaw)) {
if (typeof alias === "string") {
roles[roleName] = alias;
}
}
overrides[workflowName] = roles;
}
return overrides;
}
export function normalizeWorkflowConfig(raw: unknown): WorkflowConfig {
if (!isRecord(raw)) {
throw new Error("config.yaml root must be a mapping");
}
const defaultAgent = raw.defaultAgent;
const defaultModel = raw.defaultModel;
if (typeof defaultAgent !== "string" || typeof defaultModel !== "string") {
throw new Error("config requires defaultAgent and defaultModel");
}
return {
providers: normalizeProviders(raw.providers),
models: normalizeModels(raw.models),
agents: normalizeAgents(raw.agents),
defaultAgent,
agentOverrides: normalizeAgentOverrides(raw.agentOverrides),
defaultModel,
modelOverrides: normalizeModelOverrides(raw.modelOverrides),
};
}
export async function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig> {
const path = getConfigPath(storageRoot);
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
return normalizeWorkflowConfig(raw);
}
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
const path = getThreadsPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Store } from "@uncaged/json-cas";
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
export type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
/**
* Prepend to the role's systemPrompt when building the agent prompt.
* Contains the frontmatter deliverable format instruction derived from the
* role's output schema. Populated by `createAgent` at run time.
*/
outputFormatInstruction: string;
};
export type AgentRunResult = {
output: string;
detailHash: string;
};
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentOptions = {
name: string;
run: AgentRunFn;
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-protocol" }]
}
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test";
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
import { evaluate } from "../src/evaluate.js";
const solveIssueWorkflow: WorkflowPayload = {
name: "solve-issue",
description: "End-to-end issue resolution",
roles: {
planner: {
description: "Creates implementation plan",
systemPrompt: "You are a planning agent...",
outputSchema: "5GWKR8TN1V3JA",
},
developer: {
description: "Implements code changes",
systemPrompt: "You are a developer agent...",
outputSchema: "8CNWT4KR6D1HV",
},
reviewer: {
description: "Reviews code changes",
systemPrompt: "You are a code reviewer...",
outputSchema: "1VPBG9SM5E7WK",
},
},
conditions: {
needsClarification: {
description: "Planner requests clarification from user",
expression: "$exists(steps[-1].output.needsClarification)",
},
notApproved: {
description: "Reviewer rejected the implementation",
expression: "steps[-1].output.approved = false",
},
},
graph: {
$START: [{ role: "planner", condition: null }],
planner: [
{ role: "developer", condition: "needsClarification" },
{ role: "$END", condition: null },
],
developer: [{ role: "reviewer", condition: null }],
reviewer: [
{ role: "developer", condition: "notApproved" },
{ role: "$END", condition: null },
],
},
};
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
return {
start: {
workflow: "4KNM2PXR3B1QW",
prompt: "Fix the login bug",
},
steps,
};
}
describe("evaluate", () => {
test("$START → first role (fallback)", async () => {
const result = await evaluate(solveIssueWorkflow, makeContext([]));
expect(result).toEqual({ ok: true, value: "planner" });
});
test("condition match (notApproved → developer)", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: false },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
test("fallback when condition does not match → $END", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: true },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "$END" });
});
test("missing role in graph → error", async () => {
const context = makeContext([
{
role: "unknown-role",
output: {},
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
}
});
test("output expansion in context works with JSONata", async () => {
const context = makeContext([
{
role: "planner",
output: { needsClarification: true },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@uncaged/uwf-moderator",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/uwf-protocol": "workspace:^",
"jsonata": "^1.8.7"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
import jsonata from "jsonata";
import type { Result } from "./types.js";
const START_ROLE = "$START";
function isTruthy(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0 && !Number.isNaN(value);
}
if (typeof value === "string") {
return value.length > 0;
}
return true;
}
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
try {
const result = await jsonata(expression).evaluate(context);
return { ok: true, value: result };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
function currentRole(context: ModeratorContext): string {
if (context.steps.length === 0) {
return START_ROLE;
}
return context.steps[context.steps.length - 1].role;
}
export async function evaluate(
workflow: WorkflowPayload,
context: ModeratorContext,
): Promise<Result<string, Error>> {
const role = currentRole(context);
const transitions = workflow.graph[role];
if (transitions === undefined) {
return {
ok: false,
error: new Error(`no transitions defined for role "${role}"`),
};
}
for (const transition of transitions) {
if (transition.condition === null) {
return { ok: true, value: transition.role };
}
const conditionDef = workflow.conditions[transition.condition];
if (conditionDef === undefined) {
return {
ok: false,
error: new Error(`unknown condition "${transition.condition}"`),
};
}
const evalResult = await evaluateJsonata(conditionDef.expression, context);
if (!evalResult.ok) {
return evalResult;
}
if (isTruthy(evalResult.value)) {
return { ok: true, value: transition.role };
}
}
return {
ok: false,
error: new Error(`no transition matched for role "${role}"`),
};
}
+1
View File
@@ -0,0 +1 @@
export { evaluate } from "./evaluate.js";
+1
View File
@@ -0,0 +1 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-protocol" }]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@uncaged/uwf-protocol",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/json-cas-fs": "^0.3.0"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
+36
View File
@@ -0,0 +1,36 @@
export {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "./schemas.js";
export type {
AgentAlias,
AgentConfig,
CasRef,
ConditionDefinition,
ModelAlias,
ModelConfig,
ModeratorContext,
ProviderAlias,
ProviderConfig,
RoleDefinition,
RoleName,
Scenario,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
StepRecord,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
ThreadsIndex,
Transition,
WorkflowConfig,
WorkflowName,
WorkflowPayload,
} from "./types.js";
+86
View File
@@ -0,0 +1,86 @@
import type { JSONSchema } from "@uncaged/json-cas";
const ROLE_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "systemPrompt", "outputSchema"],
properties: {
description: { type: "string" },
systemPrompt: { type: "string" },
outputSchema: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const CONDITION_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "expression"],
properties: {
description: { type: "string" },
expression: { type: "string" },
},
additionalProperties: false,
};
const TRANSITION: JSONSchema = {
type: "object",
required: ["role", "condition"],
properties: {
role: { type: "string" },
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
};
export const WORKFLOW_SCHEMA: JSONSchema = {
title: "Workflow",
type: "object",
required: ["name", "description", "roles", "conditions", "graph"],
properties: {
name: { type: "string" },
description: { type: "string" },
roles: {
type: "object",
additionalProperties: ROLE_DEFINITION,
},
conditions: {
type: "object",
additionalProperties: CONDITION_DEFINITION,
},
graph: {
type: "object",
additionalProperties: {
type: "array",
items: TRANSITION,
},
},
},
additionalProperties: false,
};
export const START_NODE_SCHEMA: JSONSchema = {
title: "StartNode",
type: "object",
required: ["workflow", "prompt"],
properties: {
workflow: { type: "string", format: "cas_ref" },
prompt: { type: "string" },
},
additionalProperties: false,
};
export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode",
type: "object",
required: ["start", "prev", "role", "output", "detail", "agent"],
properties: {
start: { type: "string", format: "cas_ref" },
prev: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
role: { type: "string" },
output: { type: "string", format: "cas_ref" },
detail: { type: "string", format: "cas_ref" },
agent: { type: "string" },
},
additionalProperties: false,
};
+160
View File
@@ -0,0 +1,160 @@
// ── 4.1 公共类型 ────────────────────────────────────────────────────
/** CAS hash — XXH64, 13-char Crockford Base32 */
export type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
export type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
export type StepRecord = {
role: string;
output: CasRef;
detail: CasRef;
agent: string;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
export type RoleDefinition = {
description: string;
systemPrompt: string;
outputSchema: CasRef;
};
export type Transition = {
role: string;
condition: string | null;
};
export type ConditionDefinition = {
description: string;
expression: string;
};
export type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>;
};
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
export type StepNodePayload = StepRecord & {
start: CasRef;
prev: CasRef | null;
};
// ── 4.4 JSONata 求值上下文 ──────────────────────────────────────────
/** JSONata 上下文中的 step — output 被展开 */
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
};
export type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** uwf thread start */
export type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
export type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread steps — single step entry */
export type StepEntry = {
hash: CasRef;
role: string;
output: unknown;
detail: CasRef;
agent: string;
timestamp: number;
};
/** uwf thread steps — start entry */
export type StartEntry = {
hash: CasRef;
workflow: CasRef;
prompt: string;
timestamp: number;
};
/** uwf thread steps output */
export type ThreadStepsOutput = {
thread: ThreadId;
workflow: CasRef;
steps: [StartEntry, ...StepEntry[]];
};
/** uwf thread fork output */
export type ThreadForkOutput = {
thread: ThreadId;
forkedFrom: {
step: CasRef;
};
};
/** uwf thread list */
export type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
// ── 4.6 配置 ────────────────────────────────────────────────────────
/** Alias types for config references */
export type AgentAlias = string;
export type ModelAlias = string;
export type ProviderAlias = string;
export type WorkflowName = string;
export type RoleName = string;
export type Scenario = string;
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
};
export type ModelConfig = {
provider: ProviderAlias;
name: string;
};
export type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uncaged/workflow/config.yaml */
export type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uncaged/workflow/threads.yaml */
export type ThreadsIndex = Record<ThreadId, CasRef>;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
@@ -12,6 +12,7 @@ import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
function throwCursorSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createDocxDiffAgent } from "../src/agent.js";
describe("createDocxDiffAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
});
});
@@ -0,0 +1,113 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { runDocxDiff } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("runDocxDiff", () => {
test("exit 0: success, returns DifferMeta JSON", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "original.docx");
const modifiedDocx = join(dir, "modified.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// simulate docx-diff creating the diff file
writeFileSync(diffDocx, "");
const raw = await runDocxDiff(
{ command: "docx-diff" },
sourceDocx,
modifiedDocx,
diffDocx,
spawnFn,
);
const meta = JSON.parse(raw);
expect(meta.sourceDocx).toBe(sourceDocx);
expect(meta.modifiedDocx).toBe(modifiedDocx);
expect(meta.diffDocx).toBe(diffDocx);
expect(spawnFn.mock.calls[0][1]).toEqual([
sourceDocx,
modifiedDocx,
"--output",
"docx",
"--out-file",
diffDocx,
]);
});
test("exit 1 (changes found): treated as success", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "s.docx");
const modifiedDocx = join(dir, "m.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
).resolves.toBeDefined();
});
test("exit 2: throws error", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("docx-diff failed");
});
test("timeout: throws error", async () => {
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("timed out");
});
test("throws when diff file not created", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// do NOT create diffDocx
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
).rejects.toThrow("diff file not found");
});
test("uses PATH docx-diff when command is null", async () => {
const dir = tempDir();
const diffDocx = join(dir, "diff.docx");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
});
});
@@ -0,0 +1,29 @@
{
"name": "@uncaged/workflow-agent-docx-diff",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-util": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,29 @@
import * as z from "zod/v4";
import { dirname, join } from "node:path";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { WriterMeta } from "@uncaged/workflow-template-document";
import { runDocxDiff } from "./runner.js";
import type { DocxDiffAgentConfig } from "./types.js";
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
const raw = await runDocxDiff(
config,
writerMeta.sourceDocx,
writerMeta.outputDocx,
diffDocx,
);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -0,0 +1,3 @@
export { createDocxDiffAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { DocxDiffAgentConfig } from "./types.js";
@@ -0,0 +1,17 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to docx-diff CLI binary; null uses PATH.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,47 @@
import { stat } from "node:fs/promises";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { DocxDiffAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("docx-diff: timed out");
throw new Error(`docx-diff: spawn failed: ${e.message}`);
}
export async function runDocxDiff(
config: DocxDiffAgentConfig,
sourceDocx: string,
modifiedDocx: string,
diffDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<string> {
const command = config.command ?? "docx-diff";
const result = await spawnCliFn(
command,
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null },
);
if (!result.ok) {
const e = result.error;
// exit 1 = changes found (normal for docx-diff)
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
// fall through to file check
} else {
throwSpawnError(e);
}
}
try {
await stat(diffDocx);
} catch {
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
}
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
}
@@ -0,0 +1,3 @@
export type DocxDiffAgentConfig = {
command: string | null;
};
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-template-document" }
]
}
@@ -14,6 +14,7 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
function throwHermesSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createOfficeAgent } from "../src/agent.js";
describe("createOfficeAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
});
test("has outputDir in configSchema required", () => {
const schema = packageDescriptor.configSchema as { required: string[] };
expect(schema.required).toContain("outputDir");
});
});
@@ -0,0 +1,129 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { editDocument, generateDocument } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `office-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("generateDocument", () => {
test("calls office-agent create with correct args and returns outputDocx path", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
// Simulate CLI creating the file
const outFile = join(base, "thread1", "output.docx");
mkdirSync(join(base, "thread1"), { recursive: true });
writeFileSync(outFile, "");
const result = await generateDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"thread1",
"Write a report",
spawnFn,
);
expect(result.outputDocx).toBe(outFile);
expect(result.sourceDocx).toBeNull();
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
});
test("uses PATH office-agent when command is null", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
mkdirSync(join(base, "t2"), { recursive: true });
writeFileSync(join(base, "t2", "output.docx"), "");
await generateDocument(
{ outputDir: base, command: null, timeout: null },
"t2",
"Generate",
spawnFn,
);
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
});
test("throws on non_zero_exit", async () => {
const base = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
).rejects.toThrow("office-agent failed (exit 1)");
});
test("throws on timeout", async () => {
const base = tempDir();
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
).rejects.toThrow("office-agent: timed out");
});
test("throws when output file not created", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Do NOT create output.docx
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
).rejects.toThrow("output file not found");
});
});
describe("editDocument", () => {
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
const base = tempDir();
// Create a fake inputDocx
const inputFile = join(base, "source.docx");
writeFileSync(inputFile, "original content");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Simulate CLI overwriting modified.docx
const outDir = join(base, "te1");
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "modified.docx"), "modified content");
const result = await editDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"te1",
"Edit the doc",
inputFile,
spawnFn,
);
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
});
test("throws on spawn_failed", async () => {
const base = tempDir();
const inputFile = join(base, "src.docx");
writeFileSync(inputFile, "");
const spawnFn = makeSpawn(
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
);
await expect(
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
).rejects.toThrow("spawn failed");
});
});
@@ -0,0 +1,25 @@
{
"name": "@uncaged/workflow-agent-office",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,44 @@
import * as z from "zod/v4";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { editDocument, generateDocument } from "./runner.js";
import type { OfficeAgentConfig } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
type ParsedInput = { prompt: string; inputDocx: string | null };
function parseStartInput(content: string): ParsedInput {
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
if (typeof parsed.prompt === "string") {
return {
prompt: parsed.prompt,
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
};
}
} catch {
// not JSON — treat whole content as prompt, generate mode
}
return { prompt: content, inputDocx: null };
}
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`);
let raw: string;
if (inputDocx === null) {
const result = await generateDocument(config, ctx.threadId, prompt);
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
} else {
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx });
}
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -0,0 +1,3 @@
export { createOfficeAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { OfficeAgentConfig } from "./types.js";
@@ -0,0 +1,26 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: {
type: "string",
description: "Root directory for workflow outputs; subdirs are created per threadId.",
},
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to office-agent CLI binary; null uses PATH.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,65 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { OfficeAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
async function assertFileExists(path: string): Promise<void> {
try {
await stat(path);
} catch {
throw new Error(`office-agent: output file not found: ${path}`);
}
}
export async function generateDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: null }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
const outputDocx = join(outputDir, "output.docx");
await assertFileExists(outputDocx);
return { outputDocx, sourceDocx: null };
}
export async function editDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
inputDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: string }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const originalDocx = join(outputDir, "original.docx");
const modifiedDocx = join(outputDir, "modified.docx");
await copyFile(inputDocx, originalDocx);
await copyFile(inputDocx, modifiedDocx);
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
await assertFileExists(modifiedDocx);
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
}
@@ -0,0 +1,5 @@
export type OfficeAgentConfig = {
outputDir: string;
command: string | null;
timeout: number | null;
};
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util" },
{ "path": "../workflow-util-agent" }
]
}
+8
View File
@@ -4,6 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
<script>
(function () {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
<body>
<div id="root"></div>
+13 -1
View File
@@ -13,11 +13,23 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"shiki": "^4.0.2"
"react-router": "^7.15.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
@@ -0,0 +1,362 @@
import { createFilter, type Plugin } from "vite";
type LimitLineOverride = {
files: string;
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type LimitLineOptions = {
maxReactFCLines: number;
maxFileLines: number;
include: RegExp;
exclude: RegExp | null;
overrides: Array<LimitLineOverride>;
};
const DEFAULT_OPTIONS: LimitLineOptions = {
maxReactFCLines: 300,
maxFileLines: 600,
include: /\.[tj]sx$/,
exclude: null,
overrides: [],
};
type ResolvedLimits = {
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type ComponentInfo = {
name: string;
startLine: number;
lineCount: number;
};
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
// --- AST types (Rolldown ESTree subset) ---
type Identifier = {
type: "Identifier";
name: string;
};
type MemberExpression = {
type: "MemberExpression";
object: AstExpression;
property: Identifier;
};
type CallExpression = {
type: "CallExpression";
callee: AstExpression;
arguments: Array<AstExpression>;
};
type AstExpression = Identifier | MemberExpression | CallExpression | {
type: string;
[key: string]: unknown;
};
type VariableDeclarator = {
id: Identifier | null;
init: AstExpression | null;
};
type AstStatement = {
type: string;
id: Identifier | null;
declaration: AstStatement | null;
declarations: Array<VariableDeclarator>;
body: Array<AstStatement>;
[key: string]: unknown;
};
type AstProgram = {
type: "Program";
body: Array<AstStatement>;
};
// --- AST helpers ---
function isFunctionLike(node: AstExpression): boolean {
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
}
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
function isWrapperCall(node: AstExpression): boolean {
if (node.type !== "CallExpression") return false;
const call = node as CallExpression;
const callee = call.callee;
if (callee.type === "Identifier") {
return WRAPPER_NAMES.has((callee as Identifier).name);
}
if (callee.type === "MemberExpression") {
const member = callee as MemberExpression;
return member.property.type === "Identifier" && WRAPPER_NAMES.has(member.property.name);
}
return false;
}
function extractComponentNames(ast: AstProgram): Array<string> {
const names: Array<string> = [];
for (const node of ast.body) {
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
names.push(node.id.name);
continue;
}
if (node.type === "ExportNamedDeclaration" && node.declaration) {
const decl = node.declaration;
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
names.push(decl.id.name);
continue;
}
if (decl.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(decl, names);
continue;
}
}
if (node.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(node, names);
}
}
return names;
}
function collectNamesFromVarDeclaration(node: AstStatement, names: Array<string>): void {
for (const declarator of node.declarations ?? []) {
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
const init = declarator.init;
if (isFunctionLike(init)) {
names.push(declarator.id.name);
} else if (isWrapperCall(init)) {
const args = (init as CallExpression).arguments;
if (args.length > 0 && isFunctionLike(args[0])) {
names.push(declarator.id.name);
}
}
}
}
// --- Source measurement ---
function measureComponentInSource(name: string, lines: Array<string>): ComponentInfo | null {
const fnPattern = new RegExp(`^(?:export\\s+)?function\\s+${name}\\s*[(<]`);
const varPattern = new RegExp(`^(?:export\\s+)?const\\s+${name}\\s*[=:]`);
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trimStart();
const isFnDecl = fnPattern.test(trimmed);
const isVarDecl = varPattern.test(trimmed);
if (!isFnDecl && !isVarDecl) continue;
if (isFnDecl) {
const result = measureFromParams(i, lines);
if (result) return { ...result, name };
return null;
}
const result = measureFromArrow(i, lines);
if (result) return { ...result, name };
return null;
}
return null;
}
// function Foo(...) { ... } — skip params via parens, then brace-match the body
function measureFromParams(startLine: number, lines: Array<string>): ComponentInfo | null {
let parenDepth = 0;
let pastParams = false;
let braceDepth = 0;
for (let j = startLine; j < lines.length; j++) {
for (const ch of lines[j]) {
if (!pastParams) {
if (ch === "(") parenDepth++;
else if (ch === ")") {
parenDepth--;
if (parenDepth === 0) pastParams = true;
}
} else {
if (ch === "{") braceDepth++;
else if (ch === "}") {
braceDepth--;
if (braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
}
return null;
}
// const Foo = (...) => { ... } / const Foo = memo((...) => { ... })
// Find `=>` first, then brace-match from there to skip type annotations in params
function measureFromArrow(startLine: number, lines: Array<string>): ComponentInfo | null {
let arrowFound = false;
let braceDepth = 0;
let foundBrace = false;
for (let j = startLine; j < lines.length; j++) {
const line = lines[j];
for (let c = 0; c < line.length; c++) {
if (!arrowFound) {
if (line[c] === "=" && line[c + 1] === ">") {
arrowFound = true;
c++;
}
continue;
}
if (line[c] === "{") {
braceDepth++;
foundBrace = true;
} else if (line[c] === "}") {
braceDepth--;
if (foundBrace && braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
return null;
}
// --- Config resolution ---
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
const matchers = options.overrides.map((override) => ({
match: createFilter(override.files),
maxReactFCLines: override.maxReactFCLines,
maxFileLines: override.maxFileLines,
}));
return (id: string): ResolvedLimits => {
let maxReactFCLines: number | null = options.maxReactFCLines;
let maxFileLines: number | null = options.maxFileLines;
for (const matcher of matchers) {
if (matcher.match(id)) {
maxReactFCLines = matcher.maxReactFCLines;
maxFileLines = matcher.maxFileLines;
}
}
return { maxReactFCLines, maxFileLines };
};
}
function shouldProcess(id: string, options: LimitLineOptions): boolean {
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
}
// --- Plugin ---
function viteLimitLinePlugin(
userOptions: Partial<LimitLineOptions> = {},
): Array<Plugin> {
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
const resolve = createLimitResolver(options);
const rawCodeCache = new Map<string, string>();
return [
{
name: "vite-plugin-limit-line:pre",
enforce: "pre",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
rawCodeCache.set(id, code);
const limits = resolve(id);
if (limits.maxFileLines === null) return null;
const totalLines = code.split("\n").length;
if (totalLines > limits.maxFileLines) {
this.error(
[
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
` file: ${id}`,
"",
"How to fix:",
" Split this file into smaller modules — extract related types, helpers,",
" or sub-components into separate files and re-export from an index.ts.",
].join("\n"),
);
}
return null;
},
},
{
name: "vite-plugin-limit-line:fc",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxReactFCLines === null) return null;
const ast = this.parse(code) as unknown as AstProgram;
const componentNames = extractComponentNames(ast);
if (componentNames.length === 0) return null;
const raw = rawCodeCache.get(id) ?? code;
rawCodeCache.delete(id);
const rawLines = raw.split("\n");
const maxFCLines = limits.maxReactFCLines;
const violations: Array<ComponentInfo> = [];
for (const name of componentNames) {
const info = measureComponentInSource(name, rawLines);
if (info && info.lineCount > maxFCLines) {
violations.push(info);
}
}
if (violations.length > 0) {
const details = violations
.map(
(v) =>
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${maxFCLines})`,
)
.join("\n");
this.error(
[
`[vite-limit-line] React component too long in ${id}:`,
details,
"",
"How to fix:",
" Break each oversized component into smaller ones. Extract reusable",
" sections into child components, move complex logic into custom hooks,",
" and keep each component focused on a single responsibility.",
].join("\n"),
);
}
return null;
},
buildEnd() {
rawCodeCache.clear();
},
},
];
}
export { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };
+12 -49
View File
@@ -1,75 +1,38 @@
import { useState } from "react";
import { Navigate, Outlet, useParams } from "react-router";
import { clearApiKey, hasApiKey } from "./api.ts";
import { LoginPage } from "./components/login.tsx";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
import { useTheme } from "./hooks/use-theme.tsx";
export function App() {
export function Layout() {
const [authed, setAuthed] = useState(hasApiKey());
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
useHashRoute();
const { client } = useParams();
const [showRun, setShowRun] = useState(false);
const { theme, toggleTheme } = useTheme();
if (!authed) {
return <LoginPage onLogin={() => setAuthed(true)} />;
return <Navigate to="/login" replace />;
}
return (
<div className="flex h-screen">
<div className="flex h-screen bg-background">
<Sidebar
view={view}
client={client}
onViewChange={setView}
onClientChange={setClient}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar client={client} onRun={() => setShowRun(true)} />
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{!client && (
<div className="flex items-center justify-center h-full">
<p style={{ color: "var(--color-text-muted)" }}>
Select an client from the sidebar to get started.
</p>
</div>
)}
{client && view === "threads" && threadId === null && (
<ThreadList client={client} onSelect={setThreadId} />
)}
{client && view === "threads" && threadId !== null && (
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{client && view === "workflows" && workflowName === null && (
<WorkflowList client={client} onSelect={setWorkflowName} />
)}
{client && view === "workflows" && workflowName !== null && (
<WorkflowDetail
client={client}
workflowName={workflowName}
onBack={() => setWorkflowName(null)}
/>
)}
<Outlet />
</div>
</main>
{showRun && client && (
<RunDialog
client={client}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setThreadId(id);
}}
/>
)}
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
</div>
);
}
@@ -0,0 +1,31 @@
import { Loader2, Users } from "lucide-react";
import { Navigate } from "react-router";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function ClientRedirect() {
const { status, data } = useFetch(() => listClients(), []);
if (status === "loading") {
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading clients...</p>
</div>
);
}
if (status === "ok" && data.length > 0) {
return <Navigate to={`/${data[0].name}/threads`} replace />;
}
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No client selected</p>
<p className="text-xs text-muted-foreground">
Select a client from the sidebar to get started.
</p>
</div>
);
}
@@ -1,14 +1,18 @@
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { setApiKey } from "../api.ts";
import { useTheme } from "../hooks/use-theme.tsx";
import { Button } from "./ui/button.tsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card.tsx";
import { Input } from "./ui/input.tsx";
type Props = {
onLogin: () => void;
};
export function LoginPage({ onLogin }: Props) {
export function LoginPage() {
const navigate = useNavigate();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { theme, toggleTheme } = useTheme();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -17,7 +21,6 @@ export function LoginPage({ onLogin }: Props) {
setLoading(true);
setError(null);
// Test the key by hitting the endpoints list
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
@@ -40,56 +43,59 @@ export function LoginPage({ onLogin }: Props) {
}
setApiKey(key.trim());
onLogin();
navigate("/", { replace: true });
}
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--color-bg)" }}
>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
<div className="min-h-screen flex items-center justify-center bg-background relative">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 transition-colors duration-200"
onClick={toggleTheme}
>
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
Workflow Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
Enter your API key to continue
</p>
<form onSubmit={handleSubmit}>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !key.trim()}
className="w-full px-3 py-2 rounded text-sm font-medium"
style={{
background: "var(--color-accent)",
color: "var(--color-bg)",
opacity: loading || !key.trim() ? 0.5 : 1,
}}
>
{loading ? "Verifying..." : "Login"}
</button>
</form>
</div>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
<Settings className="h-5 w-5" />
Workflow Dashboard
</CardTitle>
<CardDescription>Enter your API key to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="transition-all duration-200"
/>
{error && (
<p className="text-xs text-destructive flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error}
</p>
)}
<Button
type="submit"
disabled={loading || !key.trim()}
className="w-full transition-all duration-200"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</span>
) : (
"Login"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -52,19 +52,23 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
if (html !== null) {
return (
<div
className="rounded overflow-x-auto text-xs my-2"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
<div className="relative rounded-lg border border-border overflow-hidden my-3">
{lang !== "text" && (
<span className="absolute top-2 right-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-mono">
{lang}
</span>
)}
<div
className="overflow-x-auto text-xs"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
return (
<pre
className="rounded overflow-x-auto text-xs my-2 p-3"
style={{ background: "var(--color-bg)" }}
>
<pre className="rounded-lg overflow-x-auto text-xs my-3 p-3 bg-muted/50 border border-border">
<code>{code}</code>
</pre>
);
@@ -80,8 +84,7 @@ export function Markdown({ content }: { content: string }) {
if (isInline) {
return (
<code
className="text-xs px-1 py-0.5 rounded"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
className="bg-muted rounded px-1.5 py-0.5 text-[13px] font-mono text-foreground"
{...props}
>
{children}
@@ -91,7 +94,7 @@ export function Markdown({ content }: { content: string }) {
return <CodeBlock className={className}>{children}</CodeBlock>;
},
p({ children }) {
return <p className="my-1.5 leading-relaxed">{children}</p>;
return <p className="my-2 leading-relaxed">{children}</p>;
},
ul({ children }) {
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
@@ -100,20 +103,25 @@ export function Markdown({ content }: { content: string }) {
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
},
h1({ children }) {
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>;
return (
<h1 className="text-lg font-bold mt-3 mb-2 border-b border-border pb-1">
{children}
</h1>
);
},
h2({ children }) {
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>;
return (
<h2 className="text-base font-bold mt-2 mb-2 border-b border-border pb-1">
{children}
</h2>
);
},
h3({ children }) {
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote
className="border-l-2 pl-3 my-2 text-sm"
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
>
<blockquote className="border-l-2 border-ring pl-3 my-2 text-sm text-muted-foreground bg-muted/30 rounded-r-md py-2">
{children}
</blockquote>
);
@@ -1,14 +1,26 @@
import { CheckCircle2, Clock, MessageSquare, Rocket, User, XCircle } from "lucide-react";
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
import { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
client: "#3b82f6",
extractor: "#f59e0b",
};
const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
function roleColor(role: string): string {
return ROLE_COLORS[role] ?? "var(--color-accent)";
function roleHue(role: string): number {
let hash = 0;
for (let i = 0; i < role.length; i++) {
hash = (hash * 31 + role.charCodeAt(i)) | 0;
}
return ROLE_HUES[Math.abs(hash) % ROLE_HUES.length];
}
function roleBadgeStyle(role: string): { backgroundColor: string; borderColor: string } {
const hue = roleHue(role);
return {
backgroundColor: `oklch(0.58 0.12 ${hue} / 0.85)`,
borderColor: `oklch(0.58 0.12 ${hue} / 0.25)`,
};
}
function formatTime(ts: number | null): string | null {
@@ -18,99 +30,86 @@ function formatTime(ts: number | null): string | null {
function StartCard({ record }: { record: ThreadStartRecord }) {
return (
<div
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<Card className="p-4 transition-all duration-200 overflow-hidden relative">
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🚀</span>
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
{record.workflow}
</span>
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
}}
>
<Rocket className="h-5 w-5 text-primary" />
<span className="font-semibold text-foreground">{record.workflow}</span>
<Badge variant={record.status === "active" ? "success" : "secondary"}>
{record.status}
</span>
</Badge>
</div>
{record.prompt !== null && (
<div
className="mt-2 p-3 rounded text-sm border-l-2"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-accent)",
color: "var(--color-text)",
}}
>
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
<div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
<div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
Prompt
</div>
<Markdown content={record.prompt} />
</div>
)}
</div>
</Card>
);
}
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const color = roleColor(record.role);
const style = roleBadgeStyle(record.role);
return (
<div
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
<Card
className={cn(
"p-3 text-sm transition-all duration-200 border-l-4",
highlighted && "wf-record-card-highlight",
)}
style={{ borderLeftColor: style.borderColor }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
style={{ background: color, color: "#fff" }}
className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
style={{ backgroundColor: style.backgroundColor }}
>
<User className="h-3 w-3" />
{record.role}
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
</Card>
);
}
function ResultCard({ record }: { record: WorkflowResultRecord }) {
const success = record.returnCode === 0;
return (
<div
className="p-4 rounded-lg border"
style={{
background: "var(--color-surface)",
borderColor: success ? "var(--color-success)" : "var(--color-error)",
}}
<Card
className={cn(
"p-4 transition-all duration-200 border-l-4",
success ? "border-l-success" : "border-l-destructive",
)}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{success ? "✅" : "❌"}</span>
{success ? (
<CheckCircle2 className="h-5 w-5 text-success" />
) : (
<XCircle className="h-5 w-5 text-destructive" />
)}
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: success ? "var(--color-success)" : "var(--color-error)",
color: "#fff",
}}
>
<Badge variant="outline" className="font-mono">
exit {record.returnCode}
</span>
</Badge>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
</Card>
);
}
@@ -1,14 +1,27 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Button } from "./ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Textarea } from "./ui/textarea.tsx";
type Props = {
client: string;
onClose: () => void;
onCreated: (threadId: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function RunDialog({ client, onClose, onCreated }: Props) {
export function RunDialog({ client, open, onOpenChange }: Props) {
const navigate = useNavigate();
const workflows = useFetch(() => listWorkflows(client), [client]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
@@ -22,7 +35,8 @@ export function RunDialog({ client, onClose, onCreated }: Props) {
setError(null);
try {
const result = await runThread(client, workflow, prompt);
onCreated(result.threadId);
onOpenChange(false);
navigate(`/${client}/threads/${result.threadId}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
@@ -30,95 +44,54 @@ export function RunDialog({ client, onClose, onCreated }: Props) {
}
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div
className="w-full max-w-lg p-6 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Run Thread</DialogTitle>
<DialogDescription>Start a new thread on {client}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
Workflow
</label>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<option value="">Select a workflow...</option>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
<Select value={workflow} onValueChange={setWorkflow}>
<SelectTrigger>
<SelectValue placeholder="Select a workflow..." />
</SelectTrigger>
<SelectContent>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<SelectItem key={w.name} value={w.name}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
Prompt
</label>
<textarea
<Textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
placeholder="Enter the task prompt..."
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</button>
<button
type="submit"
disabled={submitting || !workflow || !prompt}
className="px-4 py-2 text-sm rounded"
style={{
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
color: "#fff",
opacity: !workflow || !prompt ? 0.5 : 1,
}}
>
</Button>
<Button type="submit" disabled={submitting || !workflow || !prompt}>
{submitting ? "Starting..." : "Run"}
</button>
</div>
</Button>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,109 +1,131 @@
import { useEffect } from "react";
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
import { useLocation, useNavigate, useParams } from "react-router";
import type { ClientEndpoint } from "../api.ts";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
import { Button } from "./ui/button.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Separator } from "./ui/separator.tsx";
type Props = {
view: "threads" | "workflows";
client: string | null;
onViewChange: (v: "threads" | "workflows") => void;
onClientChange: (a: string | null) => void;
onLogout: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
};
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
const { client } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { status, data } = useFetch(() => listClients(), []);
const clients: ClientEndpoint[] = status === "ok" ? data : [];
// Auto-select first client when none is selected
useEffect(() => {
if (client === null && clients.length > 0) {
onClientChange(clients[0].name);
}
}, [client, clients, onClientChange]);
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
{ key: "threads" as const, label: "Threads", icon: Zap },
{ key: "workflows" as const, label: "Workflows", icon: Package },
];
return (
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
<aside className="w-56 border-r border-border flex flex-col bg-sidebar">
<div className="p-4 border-b border-primary/20">
<h1 className="text-xl font-bold text-foreground tracking-tight">Workflow</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">Dashboard</p>
</div>
{/* Client selector */}
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
<div className="px-3 py-3">
<label
className="block text-xs font-medium mb-1"
style={{ color: "var(--color-text-muted)" }}
className="block text-xs font-medium mb-1.5 text-muted-foreground"
htmlFor="client-select"
>
Client
</label>
<select
id="client-select"
className="w-full rounded px-2 py-1.5 text-xs"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
value={client ?? ""}
onChange={(e) => onClientChange(e.target.value || null)}
disabled={status === "loading"}
>
{status === "loading" ? (
<option value="">Loading</option>
) : clients.length === 0 ? (
<option value="">No clients online</option>
) : (
clients.map((a) => (
<option key={a.name} value={a.name}>
{a.status === "online" ? "🟢" : "🔴"} {a.name}
</option>
))
)}
</select>
</div>
{/* View navigation */}
<nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => (
<button
type="button"
key={item.key}
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
{status === "loading" ? (
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</div>
) : clients.length === 0 ? (
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center">
No clients online
</div>
) : (
<Select
value={client ?? ""}
onValueChange={(name) => {
if (name) navigate(`/${name}/${view}`);
}}
>
{item.icon} {item.label}
</button>
<SelectTrigger className="h-8 text-xs transition-colors duration-200">
<SelectValue placeholder="Select client…" />
</SelectTrigger>
<SelectContent>
{clients.map((a) => (
<SelectItem key={a.name} value={a.name} className="text-xs">
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
)}
/>
{a.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Separator />
<nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => (
<Button
key={item.key}
variant={view === item.key ? "secondary" : "ghost"}
size="sm"
className={cn(
"w-full justify-start gap-2 transition-colors duration-200",
view === item.key
? "text-foreground border-l-2 border-primary rounded-l-none"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => {
if (client) navigate(`/${client}/${item.key}`);
}}
>
<item.icon className="h-4 w-4" />
{item.label}
</Button>
))}
</nav>
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={onLogout}
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
style={{ color: "var(--color-text-muted)" }}
<Separator />
<div className="p-2 space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onToggleTheme}
>
🚪 Logout
</button>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onLogout}
>
<LogOut className="h-4 w-4" />
Logout
</Button>
</div>
</aside>
);
@@ -1,5 +1,7 @@
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClientHealth } from "../api.ts";
import { Button } from "./ui/button.tsx";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
@@ -8,14 +10,29 @@ type Props = {
onRun: () => void;
};
function statusLabel(status: HealthStatus): { text: string; color: string } {
function StatusIndicator({ status }: { status: HealthStatus }) {
if (status === "connected") {
return { text: "● Connected", color: "var(--color-success)" };
return (
<span className="flex items-center gap-1.5 text-xs text-success transition-colors duration-200">
<Wifi className="h-3.5 w-3.5" />
Connected
</span>
);
}
if (status === "reconnecting") {
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
return (
<span className="flex items-center gap-1.5 text-xs text-warning transition-colors duration-200">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Reconnecting
</span>
);
}
return { text: "● Offline", color: "var(--color-error)" };
return (
<span className="flex items-center gap-1.5 text-xs text-destructive transition-colors duration-200">
<WifiOff className="h-3.5 w-3.5" />
Offline
</span>
);
}
export function StatusBar({ client, onRun }: Props) {
@@ -48,32 +65,24 @@ export function StatusBar({ client, onRun }: Props) {
return () => clearInterval(interval);
}, [checkHealth]);
const label = statusLabel(status);
return (
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
<div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}>
<span className="text-muted-foreground">
{client ? `Client: ${client}` : "No client selected"}
</span>
<button
type="button"
onClick={onRun}
<Button
variant="default"
size="sm"
disabled={!client}
className="px-3 py-1 rounded text-xs font-medium"
style={{
background: client ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: client ? 1 : 0.5,
}}
onClick={onRun}
className="h-7 gap-1.5 transition-all duration-200"
>
Run Thread
</button>
<Play className="h-3.5 w-3.5" />
Run Thread
</Button>
</div>
<span style={{ color: label.color }}>{label.text}</span>
<StatusIndicator status={status} />
</div>
);
}
@@ -1,4 +1,6 @@
import { AlertCircle, ArrowLeft, Layers, Loader2, Pause, Play, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
getThread,
getWorkflowDescriptor,
@@ -11,14 +13,12 @@ import {
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
import { Badge } from "./ui/badge.tsx";
import { Button } from "./ui/button.tsx";
import { Card } from "./ui/card.tsx";
import { ResizablePanel } from "./ui/resizable-panel.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
client: string;
threadId: string;
onBack: () => void;
};
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
for (const r of records) {
if (r.type === "thread-start") return r.workflow;
@@ -53,7 +53,11 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
return states;
}
export function ThreadDetail({ client, threadId, onBack }: Props) {
export function ThreadDetail() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const threadId = params.threadId as string;
const sse = useSSE(client, threadId);
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
@@ -93,28 +97,23 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
return m;
}, [records]);
// Track which occurrence to jump to next per role (cycling)
const clickCycleRef = useRef<Map<string, number>>(new Map());
const handleGraphNodeClick = useCallback(
(nodeId: string) => {
// Only allow clicks on lit (non-default) nodes
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
// __start__: scroll to the first record (thread-start prompt)
if (nodeId === "__start__") {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// __end__: scroll to bottom
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
// Role nodes: cycle through occurrences
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
@@ -152,7 +151,7 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(client, threadId);
setActionStatus(`${action} sent ✓`);
setActionStatus(null);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
}
@@ -161,88 +160,84 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
return (
<div>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
<Button
variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={() => navigate(`/${client}/threads`)}
>
Back to threads
</button>
<div className="flex gap-2">
<button
type="button"
<ArrowLeft className="h-4 w-4" />
Back to threads
</Button>
<div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
>
Pause
</button>
<button
type="button"
<Pause className="h-3.5 w-3.5 text-warning" />
Pause
</Button>
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
>
Resume
</button>
<button
type="button"
<Play className="h-3.5 w-3.5 text-success" />
Resume
</Button>
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
>
Kill
</button>
<X className="h-3.5 w-3.5 text-destructive" />
Kill
</Button>
</div>
</div>
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
<h2 className="text-xl font-semibold mb-2 font-mono tracking-tight flex items-center gap-2 flex-wrap">
<span>{threadId}</span>
{sse.connected && !sse.completed && (
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
>
<Badge variant="success" className="animate-pulse flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-success-foreground" />
Live
</span>
</Badge>
)}
</h2>
{actionStatus && (
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
<Badge variant="secondary" className="mb-4 text-xs font-normal">
{actionStatus}
</p>
</Badge>
)}
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<div
className="shrink-0"
<ResizablePanel
defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
alignSelf: "flex-start",
}}
>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
<Card className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50 border-b border-border">
<span className="font-mono flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
Workflow graph
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
<span className="ml-2 text-foreground">{workflowName}</span>
)}
</span>
<span>
<span className="tabular-nums">
{descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"}
</span>
@@ -255,19 +250,25 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
</Card>
</ResizablePanel>
)}
<div className="flex-1 min-w-0">
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm">Loading thread...</span>
</div>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
<div className="flex items-center gap-2 py-8 justify-center text-destructive">
<AlertCircle className="h-5 w-5" />
<span className="text-sm">Error: {error}</span>
</div>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
<div className="border-l-2 border-border ml-2 pl-4 space-y-3">
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
@@ -278,18 +279,21 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
<div
key={key}
data-record-index={i}
className="relative"
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role);
}}
>
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
<RecordCard record={r} highlighted={flash} />
</div>
);
}
return (
<div key={key} data-record-index={i}>
<div key={key} data-record-index={i} className="relative">
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
<RecordCard record={r} highlighted={false} />
</div>
);
@@ -1,17 +1,37 @@
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
type Props = {
client: string;
onSelect: (id: string) => void;
};
function statusVariant(status: string): "success" | "destructive" | "secondary" {
if (status === "completed") return "success";
if (status === "failed") return "destructive";
return "secondary";
}
export function ThreadList({ client, onSelect }: Props) {
export function ThreadList() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const { status, data, error } = useFetch(() => listThreads(client), [client]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading threads...</p>
</div>
);
if (status === "error")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">Error: {error}</p>
</div>
);
const threads = [...data.threads].sort((a, b) => {
if (!a.startedAt && !b.startedAt) return 0;
@@ -22,51 +42,44 @@ export function ThreadList({ client, onSelect }: Props) {
return (
<div>
<h2 className="text-xl font-semibold mb-4">Threads</h2>
<h2 className="text-xl font-semibold tracking-tight mb-4">Threads</h2>
{threads.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Zap className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No threads</p>
<p className="text-xs text-muted-foreground">
Run a workflow to create your first thread.
</p>
</div>
) : (
<div className="space-y-2">
{threads.map((t) => (
<button
type="button"
<Card
key={t.threadId}
onClick={() => onSelect(t.threadId)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
>
<div className="flex items-center justify-between">
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
{t.threadId}
</code>
<code className="font-mono text-sm text-foreground">{t.threadId}</code>
{t.status && (
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background:
t.status === "completed"
? "var(--color-success)"
: t.status === "failed"
? "var(--color-error)"
: "var(--color-accent)",
color: "#000",
}}
>
<Badge variant={statusVariant(t.status)} className="text-xs">
{t.status}
</span>
</Badge>
)}
</div>
{t.workflow && (
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
<p className="text-sm mt-1 font-medium text-foreground flex items-center gap-1.5">
<Workflow className="h-3.5 w-3.5 text-muted-foreground" />
{t.workflow}
</p>
)}
{t.startedAt && (
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
<p className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3" />
{t.startedAt}
</p>
)}
</button>
</Card>
))}
</div>
)}
@@ -0,0 +1,30 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
success: "border-transparent bg-success text-success-foreground shadow",
warning: "border-transparent bg-warning text-warning-foreground shadow",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type BadgeProps = HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -0,0 +1,45 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success: "border border-success text-success hover:bg-success/10",
warning: "border border-warning text-warning hover:bg-warning/10",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
@@ -0,0 +1,36 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -0,0 +1,7 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
@@ -0,0 +1,104 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
function DialogOverlay({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,17 @@
import type { InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,73 @@
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils.ts";
type Props = {
defaultWidth: number;
minWidth: number;
maxWidth: number;
className: string | null;
style: CSSProperties | null;
children: React.ReactNode;
};
export function ResizablePanel({
defaultWidth,
minWidth,
maxWidth,
className,
style,
children,
}: Props) {
const [width, setWidth] = useState(defaultWidth);
const dragging = useRef(false);
const startX = useRef(0);
const startW = useRef(0);
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startW.current = width;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const delta = e.clientX - startX.current;
const next = Math.min(maxWidth, Math.max(minWidth, startW.current + delta));
setWidth(next);
},
[minWidth, maxWidth],
);
const onPointerUp = useCallback(() => {
dragging.current = false;
}, []);
return (
<div
className={cn("relative shrink-0", className)}
style={{ ...style, width }}
>
{children}
<div
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="absolute inset-y-0 left-1/2 w-px bg-border opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
function ScrollArea({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
@@ -0,0 +1,148 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
function SelectTrigger({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectScrollUpButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

Some files were not shown because too many files have changed in this diff Show More