mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-14 19:25:35 +00:00
feat(agent-core-v2): add generated config and wire-protocol manifests (#2086)
* feat(agent-core-v2): add generated config section manifest - add scripts/gen-config-manifest.mts: drains the live registerConfigSection / registerConfigOverlay contributions and renders docs/config-manifest.toml in the on-disk config.toml shape (owner, scope, registered defaults, env bindings, schema fields) - add a gen:config-manifest package script (--check mode included) and a freshness test that rebuilds the manifest and compares byte-for-byte - point the agent-core-dev config skill and the package AGENTS.md at the generated manifest instead of the stale hand-maintained ownership map * feat(agent-core-v2): add generated wire-protocol manifest - add scripts/gen-wire-manifest.mts to generate docs/wire-manifest.d.ts from defineOp registrations (payload interfaces, persist policy, toEvent, cross-reducers) plus a WirePayloadMap - extract shared JSON Schema helpers from gen-config-manifest.mts into scripts/lib/jsonSchema.mts - add gen:wire-manifest script and wireManifest.test.ts freshness check - document the manifest in packages/agent-core-v2/AGENTS.md * fix(agent-core-v2): keep array-of-tables manifest sections fully commented A bare `[hooks]` header parses as a plain table, which array sections reject on load; emit only the commented `[[hooks]]` shape so the manifest matches the on-disk config.toml shape it documents. Addresses a Codex review comment on PR #2086.
This commit is contained in:
parent
188c0fcbf7
commit
5240b5c83c
10 changed files with 2514 additions and 13 deletions
|
|
@ -242,20 +242,12 @@ When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay
|
|||
|
||||
`config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain registers it via `IConfigRegistry.registerSection`. Cross-section env behavior (e.g. `KIMI_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay`. To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself.
|
||||
|
||||
## Ownership map (current)
|
||||
## Ownership map (generated)
|
||||
|
||||
| Section | Owner | Layer | Status |
|
||||
|---|---|---|---|
|
||||
| `providers` / `defaultProvider` | `kosongConfig` (types: `kosong/provider`) | L3/L2 | owner-owned (`IProviderService` CRUD) |
|
||||
| `experimental` | `flag` | L3 | owner-owned |
|
||||
| `thinking` | `kosongConfig` (type: `kosong/model/thinking`) | L3/L2 | owner-owned |
|
||||
| `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) |
|
||||
| `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) |
|
||||
| `session` | `config` | L2 | in config |
|
||||
| `models` / `defaultModel` | `kosongConfig` (types: `kosong/model`) | L3/L2 | owner-owned (`IModelService` CRUD) |
|
||||
| `hooks` | `externalHooks` | L4 | owner-owned |
|
||||
| `permission` | `permissionRules` | L3 | owner-owned |
|
||||
| `background` | `background` | L5 | owner-owned |
|
||||
The authoritative, always-current list of registered sections — rendered in the on-disk `config.toml` shape, with owner file, scope, defaults, env bindings, and schema fields — is generated from the live registry:
|
||||
|
||||
- `packages/agent-core-v2/docs/config-manifest.toml` (checked in; do not edit by hand).
|
||||
- Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest` (add `--check` for a freshness check; `test/app/config/configManifest.test.ts` enforces it in CI).
|
||||
|
||||
`config` must not import from any of these owner domains; that is the whole reason the schemas, TOML normalization, and env overlays live with their owners.
|
||||
|
||||
|
|
|
|||
|
|
@ -62,3 +62,5 @@ Per-domain references live in `docs/`.
|
|||
- [`docs/flag.md`](docs/flag.md) — Read **before gating behavior behind a feature flag**: declaring a flag in its owning domain and registering it at import time via `registerFlagDefinition`, checking `IFlagService.enabled(id)`, wiring the `[experimental]` config section, or deciding whether a flag is App-scope vs. per-session.
|
||||
- [`docs/errors.md`](docs/errors.md) — Read **before raising errors from a domain**: defining a co-located `XxxError`, registering a code in `ErrorCodes`/`ERROR_INFO`, translating external errors (provider/HTTP, fs, MCP) at the boundary, or (de)serializing errors across RPC/SDK with `toErrorPayload`/`fromErrorPayload`.
|
||||
- [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`.
|
||||
- [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness.
|
||||
- [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every registered wire record type as a payload interface (model, persist policy, `toEvent`, cross-reducers in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a `defineOp` call — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses.
|
||||
|
|
|
|||
352
packages/agent-core-v2/docs/config-manifest.toml
Normal file
352
packages/agent-core-v2/docs/config-manifest.toml
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# Config Section Manifest
|
||||
#
|
||||
# Generated by scripts/gen-config-manifest.mts — do not edit by hand.
|
||||
# Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest
|
||||
#
|
||||
# One [table] per registered config section, in the on-disk config.toml shape
|
||||
# (snake_case keys). Un-commented assignments are registered defaults;
|
||||
# commented "# field: type" lines describe the remaining schema fields.
|
||||
# Values resolve as: default -> config.toml -> env overlay -> memory.
|
||||
|
||||
# Index (20 sections · 1 overlay(s))
|
||||
# background src/agent/task/configSection.ts
|
||||
# cron src/app/cron/configSection.ts
|
||||
# defaultPermissionMode src/agent/permissionMode/configSection.ts
|
||||
# defaultPlanMode src/agent/plan/configSection.ts
|
||||
# experimental src/app/flag/flag.ts
|
||||
# extraAgentDirs src/app/agentFileCatalog/configSection.ts
|
||||
# extraSkillDirs src/app/skillCatalog/configSection.ts
|
||||
# hooks src/agent/externalHooks/configSection.ts
|
||||
# image src/agent/media/configSection.ts
|
||||
# loopControl src/agent/loop/configSection.ts
|
||||
# mergeAllAvailableSkills src/app/skillCatalog/configSection.ts
|
||||
# modelCatalog src/app/kosongConfig/configSection.ts
|
||||
# models src/app/kosongConfig/configSection.ts
|
||||
# permission src/agent/permissionRules/configSection.ts
|
||||
# providers src/app/kosongConfig/configSection.ts
|
||||
# services src/app/auth/configSection.ts
|
||||
# subagent src/session/subagent/configSection.ts
|
||||
# task src/agent/task/configSection.ts
|
||||
# thinking src/app/kosongConfig/configSection.ts
|
||||
# tools src/agent/toolPolicy/configSection.ts
|
||||
# (overlay) kimiModelEnvOverlay src/app/kosongConfig/envOverlay.ts
|
||||
|
||||
# ##########################################################################
|
||||
# background
|
||||
# owner: src/agent/task/configSection.ts
|
||||
# scope: core
|
||||
# hooks: stripEnv
|
||||
# env:
|
||||
# keep_alive_on_exit <- KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse)
|
||||
# max_running_tasks <- KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse)
|
||||
# ##########################################################################
|
||||
|
||||
[background]
|
||||
# max_running_tasks: integer
|
||||
# keep_alive_on_exit: boolean
|
||||
# bash_auto_background_on_timeout: boolean
|
||||
# bash_task_timeout_s: integer
|
||||
# kill_grace_period_ms: integer
|
||||
# print_wait_ceiling_s: integer
|
||||
# print_background_mode: "exit" | "drain" | "steer"
|
||||
# print_max_turns: integer
|
||||
|
||||
# ##########################################################################
|
||||
# cron
|
||||
# owner: src/app/cron/configSection.ts
|
||||
# scope: core
|
||||
# hooks: stripEnv
|
||||
# env:
|
||||
# debug <- KIMI_CRON_DEBUG (custom parse)
|
||||
# no_jitter <- KIMI_CRON_NO_JITTER (custom parse)
|
||||
# no_stale <- KIMI_CRON_NO_STALE (custom parse)
|
||||
# disabled <- KIMI_DISABLE_CRON (custom parse)
|
||||
# manual_tick <- KIMI_CRON_MANUAL_TICK (custom parse)
|
||||
# clock <- KIMI_CRON_CLOCK
|
||||
# poll_interval_ms <- KIMI_CRON_POLL_INTERVAL_MS (custom parse)
|
||||
# ##########################################################################
|
||||
|
||||
[cron]
|
||||
# (schema uses transforms; fields below come from the registered default)
|
||||
debug = false
|
||||
no_jitter = false
|
||||
no_stale = false
|
||||
disabled = false
|
||||
manual_tick = false
|
||||
|
||||
# ##########################################################################
|
||||
# defaultPermissionMode (config.toml: default_permission_mode)
|
||||
# owner: src/agent/permissionMode/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
# default_permission_mode: "manual" | "auto" | "yolo"
|
||||
|
||||
# ##########################################################################
|
||||
# defaultPlanMode (config.toml: default_plan_mode)
|
||||
# owner: src/agent/plan/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
default_plan_mode = false
|
||||
|
||||
# ##########################################################################
|
||||
# experimental
|
||||
# owner: src/app/flag/flag.ts
|
||||
# scope: core
|
||||
# hooks: custom fromToml · custom toToml
|
||||
# ##########################################################################
|
||||
|
||||
[experimental]
|
||||
# <name>: boolean
|
||||
|
||||
# ##########################################################################
|
||||
# extraAgentDirs (config.toml: extra_agent_dirs)
|
||||
# owner: src/app/agentFileCatalog/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
extra_agent_dirs = []
|
||||
|
||||
# ##########################################################################
|
||||
# extraSkillDirs (config.toml: extra_skill_dirs)
|
||||
# owner: src/app/skillCatalog/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
extra_skill_dirs = []
|
||||
|
||||
# ##########################################################################
|
||||
# hooks
|
||||
# owner: src/agent/externalHooks/configSection.ts
|
||||
# scope: core
|
||||
# hooks: custom fromToml · custom toToml
|
||||
# ##########################################################################
|
||||
|
||||
# one [[hooks]] table per entry:
|
||||
# [[hooks]]
|
||||
# event: "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" | "PermissionResult" | "UserPromptSubmit" | "Stop" | "StopFailure" | "Interrupt" | "SessionStart" | "SessionEnd" | "SubagentStart" | "SubagentStop" | "PreCompact" | "PostCompact" | "Notification"
|
||||
# matcher: string
|
||||
# command: string
|
||||
# timeout: integer
|
||||
|
||||
# ##########################################################################
|
||||
# image
|
||||
# owner: src/agent/media/configSection.ts
|
||||
# scope: core
|
||||
# hooks: stripEnv
|
||||
# env:
|
||||
# max_edge_px <- KIMI_IMAGE_MAX_EDGE_PX (custom parse)
|
||||
# read_byte_budget <- KIMI_IMAGE_READ_BYTE_BUDGET (custom parse)
|
||||
# ##########################################################################
|
||||
|
||||
[image]
|
||||
# max_edge_px: integer
|
||||
# read_byte_budget: integer
|
||||
|
||||
# ##########################################################################
|
||||
# loopControl (config.toml: loop_control)
|
||||
# owner: src/agent/loop/configSection.ts
|
||||
# scope: core
|
||||
# hooks: custom fromToml · custom toToml · stripEnv
|
||||
# env:
|
||||
# max_steps_per_turn <- KIMI_LOOP_MAX_STEPS_PER_TURN (custom parse)
|
||||
# max_retries_per_step <- KIMI_LOOP_MAX_RETRIES_PER_STEP (custom parse)
|
||||
# ##########################################################################
|
||||
|
||||
[loop_control]
|
||||
# max_steps_per_turn: integer
|
||||
# max_retries_per_step: integer
|
||||
# max_ralph_iterations: integer
|
||||
# reserved_context_size: integer
|
||||
# compaction_trigger_ratio: number
|
||||
|
||||
# ##########################################################################
|
||||
# mergeAllAvailableSkills (config.toml: merge_all_available_skills)
|
||||
# owner: src/app/skillCatalog/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
merge_all_available_skills = true
|
||||
|
||||
# ##########################################################################
|
||||
# modelCatalog (config.toml: model_catalog)
|
||||
# owner: src/app/kosongConfig/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
[model_catalog]
|
||||
# refresh_interval_ms: integer
|
||||
# refresh_on_start: boolean
|
||||
|
||||
# ##########################################################################
|
||||
# models
|
||||
# owner: src/app/kosongConfig/configSection.ts
|
||||
# scope: core
|
||||
# hooks: custom fromToml · custom toToml
|
||||
# ##########################################################################
|
||||
|
||||
[models]
|
||||
|
||||
# one [models."<name>"] table per entry:
|
||||
# [models."<name>"]
|
||||
# provider_id: string
|
||||
# base_url: string
|
||||
# api_key: string
|
||||
# oauth: object
|
||||
# storage: "file" | "keyring"
|
||||
# key: string
|
||||
# oauth_host: string
|
||||
# protocol: "anthropic" | "openai" | "openai_responses" | "google-genai"
|
||||
# name: string
|
||||
# aliases: string[]
|
||||
# provider: string
|
||||
# model: string
|
||||
# max_context_size: integer
|
||||
# max_input_size: integer
|
||||
# max_output_size: integer
|
||||
# capabilities: string[]
|
||||
# display_name: string
|
||||
# reasoning_key: string
|
||||
# adaptive_thinking: boolean
|
||||
# beta_api: boolean
|
||||
# support_efforts: string[]
|
||||
# default_effort: string
|
||||
# off_effort: string
|
||||
# overrides: object
|
||||
# max_context_size: integer
|
||||
# max_input_size: integer
|
||||
# max_output_size: integer
|
||||
# capabilities: string[]
|
||||
# display_name: string
|
||||
# reasoning_key: string
|
||||
# adaptive_thinking: boolean
|
||||
# support_efforts: string[]
|
||||
# default_effort: string
|
||||
# off_effort: string
|
||||
|
||||
# ##########################################################################
|
||||
# permission
|
||||
# owner: src/agent/permissionRules/configSection.ts
|
||||
# scope: core
|
||||
# hooks: custom fromToml · custom toToml
|
||||
# ##########################################################################
|
||||
|
||||
[permission]
|
||||
# rules: object[] — one entry per item:
|
||||
# decision: "allow" | "deny" | "ask"
|
||||
# scope: "turn-override" | "session-runtime" | "project" | "user" (default: "user")
|
||||
# pattern: string
|
||||
# reason: string
|
||||
|
||||
# ##########################################################################
|
||||
# providers
|
||||
# owner: src/app/kosongConfig/configSection.ts
|
||||
# scope: core
|
||||
# hooks: custom fromToml · custom toToml · stripEnv
|
||||
# env:
|
||||
# __kimi_env__.api_key <- KIMI_MODEL_API_KEY
|
||||
# __kimi_env__.type <- KIMI_MODEL_PROVIDER_TYPE
|
||||
# __kimi_env__.base_url <- KIMI_MODEL_BASE_URL
|
||||
# ##########################################################################
|
||||
|
||||
[providers]
|
||||
|
||||
# one [providers."<name>"] table per entry:
|
||||
# [providers."<name>"]
|
||||
# model_source: "static" | "discover" | "oauth-catalog"
|
||||
# base_url: string
|
||||
# custom_headers: record<string, string>
|
||||
# default_model: string
|
||||
# type: string
|
||||
# api_key: string
|
||||
# oauth: object
|
||||
# storage: "file" | "keyring"
|
||||
# key: string
|
||||
# oauth_host: string
|
||||
# env: record<string, string>
|
||||
# source: record<string, any>
|
||||
|
||||
# ##########################################################################
|
||||
# services
|
||||
# owner: src/app/auth/configSection.ts
|
||||
# scope: core
|
||||
# hooks: custom fromToml · custom toToml
|
||||
# ##########################################################################
|
||||
|
||||
[services]
|
||||
|
||||
# [services.moonshot_search]
|
||||
# base_url: string
|
||||
# api_key: string
|
||||
# oauth: object
|
||||
# storage: "file" | "keyring"
|
||||
# key: string
|
||||
# oauth_host: string
|
||||
# custom_headers: record<string, string>
|
||||
|
||||
# [services.moonshot_fetch]
|
||||
# base_url: string
|
||||
# api_key: string
|
||||
# oauth: object
|
||||
# storage: "file" | "keyring"
|
||||
# key: string
|
||||
# oauth_host: string
|
||||
# custom_headers: record<string, string>
|
||||
|
||||
# ##########################################################################
|
||||
# subagent
|
||||
# owner: src/session/subagent/configSection.ts
|
||||
# scope: core
|
||||
# hooks: stripEnv
|
||||
# env:
|
||||
# timeout_ms <- KIMI_SUBAGENT_TIMEOUT_MS (custom parse)
|
||||
# ##########################################################################
|
||||
|
||||
[subagent]
|
||||
timeout_ms = 7200000
|
||||
|
||||
# ##########################################################################
|
||||
# task
|
||||
# owner: src/agent/task/configSection.ts
|
||||
# scope: core
|
||||
# hooks: stripEnv
|
||||
# env:
|
||||
# keep_alive_on_exit <- KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse)
|
||||
# max_running_tasks <- KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse)
|
||||
# ##########################################################################
|
||||
|
||||
[task]
|
||||
# max_running_tasks: integer
|
||||
# keep_alive_on_exit: boolean
|
||||
# bash_auto_background_on_timeout: boolean
|
||||
# bash_task_timeout_s: integer
|
||||
# kill_grace_period_ms: integer
|
||||
# print_wait_ceiling_s: integer
|
||||
# print_background_mode: "exit" | "drain" | "steer"
|
||||
# print_max_turns: integer
|
||||
|
||||
# ##########################################################################
|
||||
# thinking
|
||||
# owner: src/app/kosongConfig/configSection.ts
|
||||
# scope: core
|
||||
# hooks: stripEnv
|
||||
# env:
|
||||
# forced_effort <- KIMI_MODEL_THINKING_EFFORT
|
||||
# ##########################################################################
|
||||
|
||||
[thinking]
|
||||
# enabled: boolean
|
||||
# effort: string
|
||||
# forced_effort: string
|
||||
# keep: string
|
||||
|
||||
# ##########################################################################
|
||||
# tools
|
||||
# owner: src/agent/toolPolicy/configSection.ts
|
||||
# scope: core
|
||||
# ##########################################################################
|
||||
|
||||
[tools]
|
||||
# enabled: string[]
|
||||
# disabled: string[]
|
||||
660
packages/agent-core-v2/docs/wire-manifest.d.ts
vendored
Normal file
660
packages/agent-core-v2/docs/wire-manifest.d.ts
vendored
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
// Wire Protocol Manifest
|
||||
//
|
||||
// Generated by scripts/gen-wire-manifest.mts — do not edit by hand.
|
||||
// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest
|
||||
//
|
||||
// protocol_version: "1.5" (migrations: 1.0 -> 1.1 -> 1.2 -> 1.3 -> 1.4 -> 1.5)
|
||||
//
|
||||
// One declaration per record type registered via defineOp(...) and drained from
|
||||
// the runtime OP_REGISTRY. Every payload declaration carries its record type in
|
||||
// a `_name` field. Payload sketches use TypeScript type syntax; when a
|
||||
// named type is expanded inline, its name appears as a doc comment
|
||||
// (`/** ContextMessage */`). Bare type names (ContentPart, ContextMessage, …)
|
||||
// refer to the real types in src/ — they are intentionally not resolved here.
|
||||
// `// …` marks a capped field list. On disk (wire.jsonl) the journal opens with
|
||||
// a metadata line {"type": "metadata", "protocol_version", "created_at"}; each
|
||||
// op record is {"type", ...payload, "time"} — object payloads spread at the
|
||||
// top level, scalar payloads nest under a "payload" key.
|
||||
//
|
||||
// Declaration flags: persisted (written to the journal; absent = transient),
|
||||
// toEvent (also publishes an IEventBus fact on live dispatch), blobs (the
|
||||
// owning model offloads inline media to blob storage), cross-reducers
|
||||
// (foreign models that also reduce this record on dispatch and replay).
|
||||
|
||||
// Index (44 record types)
|
||||
// config.update profile persisted src/agent/profile/profileOps.ts
|
||||
// context_size.measured contextSize transient src/agent/contextSize/contextSizeOps.ts
|
||||
// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts
|
||||
// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts
|
||||
// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts
|
||||
// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts
|
||||
// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts
|
||||
// cron.add cron transient src/session/cron/cronOps.ts
|
||||
// cron.cursor cron transient src/session/cron/cronOps.ts
|
||||
// cron.delete cron transient src/session/cron/cronOps.ts
|
||||
// forked goal persisted src/agent/goal/goalOps.ts
|
||||
// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
|
||||
// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
|
||||
// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
|
||||
// goal.clear goal persisted src/agent/goal/goalOps.ts
|
||||
// goal.create goal persisted src/agent/goal/goalOps.ts
|
||||
// goal.update goal persisted src/agent/goal/goalOps.ts
|
||||
// interaction.request interaction persisted src/session/interaction/interactionOps.ts
|
||||
// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts
|
||||
// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts
|
||||
// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts
|
||||
// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts
|
||||
// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts
|
||||
// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts
|
||||
// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts
|
||||
// plan_mode.cancel plan persisted src/agent/plan/planOps.ts
|
||||
// plan_mode.enter plan persisted src/agent/plan/planOps.ts
|
||||
// plan_mode.exit plan persisted src/agent/plan/planOps.ts
|
||||
// plan.revision plan persisted src/agent/plan/planOps.ts
|
||||
// profile.bind profile persisted src/agent/profile/profileOps.ts
|
||||
// skill.activate skill transient src/agent/skill/skillOps.ts
|
||||
// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts
|
||||
// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts
|
||||
// task.started task persisted src/agent/task/taskOps.ts
|
||||
// task.terminated task persisted src/agent/task/taskOps.ts
|
||||
// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts
|
||||
// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts
|
||||
// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts
|
||||
// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts
|
||||
// tools.update_store todo persisted src/session/todo/todoOps.ts
|
||||
// turn.cancel turn persisted src/agent/loop/turnOps.ts
|
||||
// turn.prompt turn persisted src/agent/loop/turnOps.ts
|
||||
// turn.steer turn persisted src/agent/loop/turnOps.ts
|
||||
// usage.record usage persisted src/agent/usage/usageOps.ts
|
||||
|
||||
/**
|
||||
* model: profile · persisted
|
||||
* owner: src/agent/profile/profileOps.ts
|
||||
*/
|
||||
interface ConfigUpdatePayload {
|
||||
_name: 'config.update';
|
||||
cwd?: string;
|
||||
modelAlias?: string;
|
||||
profileName?: string;
|
||||
/** ThinkingEffort */
|
||||
thinkingEffort?: 'off' | 'on' | (string & {});
|
||||
/** ThinkingEffort */
|
||||
thinkingLevel?: 'off' | 'on' | (string & {});
|
||||
systemPrompt?: string;
|
||||
disallowedTools?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: contextSize · toEvent
|
||||
* owner: src/agent/contextSize/contextSizeOps.ts
|
||||
*/
|
||||
interface ContextSizeMeasuredPayload {
|
||||
_name: 'context_size.measured';
|
||||
length: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: contextMemory · persisted · blobs · cross-reducers: turn
|
||||
* owner: src/agent/contextMemory/contextOps.ts
|
||||
*/
|
||||
interface ContextAppendLoopEventPayload {
|
||||
_name: 'context.append_loop_event';
|
||||
/** LoopRecordedEvent */
|
||||
event: 'step.begin' | 'step.end' | 'content.part' | 'tool.call' | 'tool.result';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: contextMemory · persisted · blobs · cross-reducers: goalForkNotice, task.notificationDelivery
|
||||
* owner: src/agent/contextMemory/contextOps.ts
|
||||
*/
|
||||
interface ContextAppendMessagePayload {
|
||||
_name: 'context.append_message';
|
||||
/** ContextMessage */
|
||||
message: {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
name?: string;
|
||||
content: ('text' | 'think' | 'image_url' | 'audio_url' | 'video_url')[];
|
||||
toolCalls: {
|
||||
type: 'function';
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string | null;
|
||||
extras?: Record<string, unknown>;
|
||||
_streamIndex?: number | string;
|
||||
}[];
|
||||
toolCallId?: string;
|
||||
partial?: boolean;
|
||||
tools?: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
deferred?: true;
|
||||
}[];
|
||||
id?: string;
|
||||
providerMessageId?: string;
|
||||
origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined;
|
||||
isError?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* model: contextMemory · persisted · blobs
|
||||
* owner: src/agent/contextMemory/contextOps.ts
|
||||
* shared base: ...contextCompactionBaseShape
|
||||
*/
|
||||
type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number });
|
||||
|
||||
/**
|
||||
* model: contextMemory · persisted · blobs
|
||||
* owner: src/agent/contextMemory/contextOps.ts
|
||||
*/
|
||||
interface ContextClearPayload {
|
||||
_name: 'context.clear';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: contextMemory · persisted · blobs
|
||||
* owner: src/agent/contextMemory/contextOps.ts
|
||||
*/
|
||||
interface ContextUndoPayload {
|
||||
_name: 'context.undo';
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: cron
|
||||
* owner: src/session/cron/cronOps.ts
|
||||
*/
|
||||
interface CronAddPayload {
|
||||
_name: 'cron.add';
|
||||
/** CronTask */
|
||||
task: {
|
||||
id: string;
|
||||
cron: string;
|
||||
prompt: string;
|
||||
createdAt: number;
|
||||
recurring?: boolean;
|
||||
lastFiredAt?: number;
|
||||
tags?: Readonly<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* model: cron
|
||||
* owner: src/session/cron/cronOps.ts
|
||||
*/
|
||||
interface CronCursorPayload {
|
||||
_name: 'cron.cursor';
|
||||
id: string;
|
||||
lastFiredAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: cron
|
||||
* owner: src/session/cron/cronOps.ts
|
||||
*/
|
||||
interface CronDeletePayload {
|
||||
_name: 'cron.delete';
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: goal · persisted · cross-reducers: goalForkNotice
|
||||
* owner: src/agent/goal/goalOps.ts
|
||||
*/
|
||||
interface ForkedPayload {
|
||||
_name: 'forked';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: fullCompaction · persisted · toEvent
|
||||
* owner: src/agent/fullCompaction/compactionOps.ts
|
||||
* payload type: CompactionBeginData
|
||||
*/
|
||||
interface FullCompactionBeginPayload {
|
||||
_name: 'full_compaction.begin';
|
||||
instruction?: string;
|
||||
source: 'manual' | 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: fullCompaction · persisted
|
||||
* owner: src/agent/fullCompaction/compactionOps.ts
|
||||
*/
|
||||
interface FullCompactionCancelPayload {
|
||||
_name: 'full_compaction.cancel';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: fullCompaction · persisted
|
||||
* owner: src/agent/fullCompaction/compactionOps.ts
|
||||
*/
|
||||
interface FullCompactionCompletePayload {
|
||||
_name: 'full_compaction.complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: goal · persisted · cross-reducers: goalForkNotice
|
||||
* owner: src/agent/goal/goalOps.ts
|
||||
*/
|
||||
interface GoalClearPayload {
|
||||
_name: 'goal.clear';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: goal · persisted · cross-reducers: goalForkNotice
|
||||
* owner: src/agent/goal/goalOps.ts
|
||||
*/
|
||||
interface GoalCreatePayload {
|
||||
_name: 'goal.create';
|
||||
goalId: string;
|
||||
objective: string;
|
||||
completionCriterion?: string;
|
||||
wallClockResumedAt?: number;
|
||||
status?: 'active' | 'paused' | 'blocked' | 'complete';
|
||||
actor?: 'user' | 'model' | 'runtime' | 'system';
|
||||
budgetLimits?: {
|
||||
tokenBudget?: number;
|
||||
turnBudget?: number;
|
||||
wallClockBudgetMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* model: goal · persisted
|
||||
* owner: src/agent/goal/goalOps.ts
|
||||
*/
|
||||
interface GoalUpdatePayload {
|
||||
_name: 'goal.update';
|
||||
goalId?: string;
|
||||
status?: 'active' | 'paused' | 'blocked' | 'complete';
|
||||
reason?: string;
|
||||
turnsUsed?: number;
|
||||
tokensUsed?: number;
|
||||
wallClockMs?: number;
|
||||
wallClockResumedAt?: number;
|
||||
budgetLimits?: {
|
||||
tokenBudget?: number;
|
||||
turnBudget?: number;
|
||||
wallClockBudgetMs?: number;
|
||||
};
|
||||
actor?: 'user' | 'model' | 'runtime' | 'system';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: interaction · persisted
|
||||
* owner: src/session/interaction/interactionOps.ts
|
||||
*/
|
||||
interface InteractionRequestPayload {
|
||||
_name: 'interaction.request';
|
||||
id: string;
|
||||
kind: 'approval' | 'question' | 'user_tool';
|
||||
toolCallId?: string;
|
||||
agentId?: string;
|
||||
request: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: interaction · persisted
|
||||
* owner: src/session/interaction/interactionOps.ts
|
||||
*/
|
||||
interface InteractionResolvedPayload {
|
||||
_name: 'interaction.resolved';
|
||||
id: string;
|
||||
response: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: llm.requestTrace · persisted
|
||||
* owner: src/agent/llmRequester/llmRequestOps.ts
|
||||
*/
|
||||
interface LlmRequestPayload {
|
||||
_name: 'llm.request';
|
||||
kind: 'loop' | 'compaction';
|
||||
provider: string;
|
||||
model: string;
|
||||
modelAlias?: string;
|
||||
/** ThinkingEffort */
|
||||
thinkingEffort?: 'off' | 'on' | (string & {});
|
||||
thinkingKeep?: string;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
maxTokens?: number;
|
||||
betaApi?: boolean;
|
||||
toolSelect: boolean;
|
||||
systemPromptHash: string;
|
||||
systemPrompt?: string;
|
||||
toolsHash: string;
|
||||
messageCount: number;
|
||||
turnStep?: string;
|
||||
attempt?: string;
|
||||
projection?: 'strict' | 'media-degraded' | 'media-stripped';
|
||||
droppedCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: llm.requestTrace · persisted
|
||||
* owner: src/agent/llmRequester/llmRequestOps.ts
|
||||
*/
|
||||
interface LlmToolsSnapshotPayload {
|
||||
_name: 'llm.tools_snapshot';
|
||||
hash: string;
|
||||
tools: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: mcp.discovery · persisted
|
||||
* owner: src/agent/mcp/mcpDiscoveryOps.ts
|
||||
*/
|
||||
interface McpToolsDiscoveredPayload {
|
||||
_name: 'mcp.tools_discovered';
|
||||
serverName: string;
|
||||
hash: string;
|
||||
tools: readonly MCPToolDefinition[];
|
||||
enabledNames: string[];
|
||||
collisions?: {
|
||||
qualified: string;
|
||||
toolName: string;
|
||||
collidesWith: { kind: 'same_server', toolName: string } | { kind: 'other_server', serverName: string };
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: permissionRules · persisted
|
||||
* owner: src/agent/permissionRules/permissionRulesOps.ts
|
||||
* payload type: PermissionApprovalResultRecord
|
||||
*/
|
||||
interface PermissionRecordApprovalResultPayload {
|
||||
_name: 'permission.record_approval_result';
|
||||
turnId: number;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
action: string;
|
||||
sessionApprovalRule?: string;
|
||||
result: ApprovalResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: permissionRules
|
||||
* owner: src/agent/permissionRules/permissionRulesOps.ts
|
||||
*/
|
||||
interface PermissionRulesAddPayload {
|
||||
_name: 'permission.rules.add';
|
||||
rules: readonly PermissionRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: permissionMode · persisted · cross-reducers: permissionMode.configured
|
||||
* owner: src/agent/permissionMode/permissionModeOps.ts
|
||||
*/
|
||||
interface PermissionSetModePayload {
|
||||
_name: 'permission.set_mode';
|
||||
/** PermissionMode */
|
||||
mode: 'manual' | 'yolo' | 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: plan · persisted · toEvent
|
||||
* owner: src/agent/plan/planOps.ts
|
||||
*/
|
||||
interface PlanModeCancelPayload {
|
||||
_name: 'plan_mode.cancel';
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: plan · persisted · toEvent
|
||||
* owner: src/agent/plan/planOps.ts
|
||||
*/
|
||||
interface PlanModeEnterPayload {
|
||||
_name: 'plan_mode.enter';
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: plan · persisted · toEvent
|
||||
* owner: src/agent/plan/planOps.ts
|
||||
*/
|
||||
interface PlanModeExitPayload {
|
||||
_name: 'plan_mode.exit';
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: plan · persisted · toEvent
|
||||
* owner: src/agent/plan/planOps.ts
|
||||
*/
|
||||
interface PlanRevisionPayload {
|
||||
_name: 'plan.revision';
|
||||
id: string;
|
||||
version: number;
|
||||
path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: profile · persisted · cross-reducers: profile.activeTools
|
||||
* owner: src/agent/profile/profileOps.ts
|
||||
*/
|
||||
interface ProfileBindPayload {
|
||||
_name: 'profile.bind';
|
||||
cwd?: string;
|
||||
modelAlias?: string;
|
||||
profileName?: string;
|
||||
/** ThinkingEffort */
|
||||
thinkingEffort: 'off' | 'on' | (string & {});
|
||||
systemPrompt: string;
|
||||
activeToolNames?: string[];
|
||||
disallowedTools: string[];
|
||||
subagents?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: skill · toEvent
|
||||
* owner: src/agent/skill/skillOps.ts
|
||||
*/
|
||||
interface SkillActivatePayload {
|
||||
_name: 'skill.activate';
|
||||
/** SkillActivationOrigin */
|
||||
origin: {
|
||||
kind: 'skill_activation';
|
||||
activationId: string;
|
||||
skillName: string;
|
||||
skillArgs?: string | undefined;
|
||||
trigger: 'user-slash' | 'model-tool' | 'nested-skill';
|
||||
skillType?: string | undefined;
|
||||
skillPath?: string | undefined;
|
||||
skillSource?: 'project' | 'user' | 'extra' | 'builtin' | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* model: swarm · persisted · toEvent
|
||||
* owner: src/agent/swarm/swarmOps.ts
|
||||
*/
|
||||
interface SwarmModeEnterPayload {
|
||||
_name: 'swarm_mode.enter';
|
||||
/** SwarmModeTrigger */
|
||||
trigger: 'manual' | 'task' | 'tool';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: swarm · persisted · toEvent · cross-reducers: contextMemory
|
||||
* owner: src/agent/swarm/swarmOps.ts
|
||||
*/
|
||||
interface SwarmModeExitPayload {
|
||||
_name: 'swarm_mode.exit';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: task · persisted · toEvent
|
||||
* owner: src/agent/task/taskOps.ts
|
||||
*/
|
||||
interface TaskStartedPayload {
|
||||
_name: 'task.started';
|
||||
/** AgentTaskInfo */
|
||||
info: AgentTaskInfoByKind[AgentTaskKind];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: task · persisted · toEvent
|
||||
* owner: src/agent/task/taskOps.ts
|
||||
*/
|
||||
interface TaskTerminatedPayload {
|
||||
_name: 'task.terminated';
|
||||
/** AgentTaskInfo */
|
||||
info: AgentTaskInfoByKind[AgentTaskKind];
|
||||
outputTail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: userTool · persisted
|
||||
* owner: src/agent/userTool/userToolOps.ts
|
||||
* payload type: UserToolRegistration
|
||||
*/
|
||||
interface ToolsRegisterUserToolPayload {
|
||||
_name: 'tools.register_user_tool';
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: profile.activeTools · persisted
|
||||
* owner: src/agent/profile/profileOps.ts
|
||||
*/
|
||||
interface ToolsResetActiveToolsPayload {
|
||||
_name: 'tools.reset_active_tools';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: profile.activeTools · persisted
|
||||
* owner: src/agent/profile/profileOps.ts
|
||||
*/
|
||||
interface ToolsSetActiveToolsPayload {
|
||||
_name: 'tools.set_active_tools';
|
||||
names: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* model: userTool · persisted
|
||||
* owner: src/agent/userTool/userToolOps.ts
|
||||
*/
|
||||
interface ToolsUnregisterUserToolPayload {
|
||||
_name: 'tools.unregister_user_tool';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: todo · persisted
|
||||
* owner: src/session/todo/todoOps.ts
|
||||
*/
|
||||
interface ToolsUpdateStorePayload {
|
||||
_name: 'tools.update_store';
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: turn · persisted
|
||||
* owner: src/agent/loop/turnOps.ts
|
||||
*/
|
||||
interface TurnCancelPayload {
|
||||
_name: 'turn.cancel';
|
||||
turnId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* model: turn · persisted
|
||||
* owner: src/agent/loop/turnOps.ts
|
||||
*/
|
||||
interface TurnPromptPayload {
|
||||
_name: 'turn.prompt';
|
||||
input: readonly ContentPart[];
|
||||
/** PromptOrigin */
|
||||
origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: turn · persisted
|
||||
* owner: src/agent/loop/turnOps.ts
|
||||
*/
|
||||
interface TurnSteerPayload {
|
||||
_name: 'turn.steer';
|
||||
input: readonly ContentPart[];
|
||||
/** PromptOrigin */
|
||||
origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry';
|
||||
}
|
||||
|
||||
/**
|
||||
* model: usage · persisted
|
||||
* owner: src/agent/usage/usageOps.ts
|
||||
*/
|
||||
interface UsageRecordPayload {
|
||||
_name: 'usage.record';
|
||||
model: string;
|
||||
/** TokenUsage */
|
||||
usage: {
|
||||
inputOther: number;
|
||||
output: number;
|
||||
inputCacheRead: number;
|
||||
inputCacheCreation: number;
|
||||
};
|
||||
/** UsageRecordScope */
|
||||
usageScope?: 'session' | 'turn';
|
||||
}
|
||||
|
||||
/** Record type → payload sketch. */
|
||||
interface WirePayloadMap {
|
||||
"config.update": ConfigUpdatePayload;
|
||||
"context_size.measured": ContextSizeMeasuredPayload;
|
||||
"context.append_loop_event": ContextAppendLoopEventPayload;
|
||||
"context.append_message": ContextAppendMessagePayload;
|
||||
"context.apply_compaction": ContextApplyCompactionPayload;
|
||||
"context.clear": ContextClearPayload;
|
||||
"context.undo": ContextUndoPayload;
|
||||
"cron.add": CronAddPayload;
|
||||
"cron.cursor": CronCursorPayload;
|
||||
"cron.delete": CronDeletePayload;
|
||||
"forked": ForkedPayload;
|
||||
"full_compaction.begin": FullCompactionBeginPayload;
|
||||
"full_compaction.cancel": FullCompactionCancelPayload;
|
||||
"full_compaction.complete": FullCompactionCompletePayload;
|
||||
"goal.clear": GoalClearPayload;
|
||||
"goal.create": GoalCreatePayload;
|
||||
"goal.update": GoalUpdatePayload;
|
||||
"interaction.request": InteractionRequestPayload;
|
||||
"interaction.resolved": InteractionResolvedPayload;
|
||||
"llm.request": LlmRequestPayload;
|
||||
"llm.tools_snapshot": LlmToolsSnapshotPayload;
|
||||
"mcp.tools_discovered": McpToolsDiscoveredPayload;
|
||||
"permission.record_approval_result": PermissionRecordApprovalResultPayload;
|
||||
"permission.rules.add": PermissionRulesAddPayload;
|
||||
"permission.set_mode": PermissionSetModePayload;
|
||||
"plan_mode.cancel": PlanModeCancelPayload;
|
||||
"plan_mode.enter": PlanModeEnterPayload;
|
||||
"plan_mode.exit": PlanModeExitPayload;
|
||||
"plan.revision": PlanRevisionPayload;
|
||||
"profile.bind": ProfileBindPayload;
|
||||
"skill.activate": SkillActivatePayload;
|
||||
"swarm_mode.enter": SwarmModeEnterPayload;
|
||||
"swarm_mode.exit": SwarmModeExitPayload;
|
||||
"task.started": TaskStartedPayload;
|
||||
"task.terminated": TaskTerminatedPayload;
|
||||
"tools.register_user_tool": ToolsRegisterUserToolPayload;
|
||||
"tools.reset_active_tools": ToolsResetActiveToolsPayload;
|
||||
"tools.set_active_tools": ToolsSetActiveToolsPayload;
|
||||
"tools.unregister_user_tool": ToolsUnregisterUserToolPayload;
|
||||
"tools.update_store": ToolsUpdateStorePayload;
|
||||
"turn.cancel": TurnCancelPayload;
|
||||
"turn.prompt": TurnPromptPayload;
|
||||
"turn.steer": TurnSteerPayload;
|
||||
"usage.record": UsageRecordPayload;
|
||||
}
|
||||
|
|
@ -48,6 +48,8 @@
|
|||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"gen:contract-types": "node scripts/gen-contract-types.mjs",
|
||||
"gen:config-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-config-manifest.mts",
|
||||
"gen:wire-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-wire-manifest.mts",
|
||||
"lint:domain": "node scripts/check-domain-layers.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"dep-graph:analyze": "tsx scripts/dep-graph/cli.ts",
|
||||
|
|
|
|||
367
packages/agent-core-v2/scripts/gen-config-manifest.mts
Normal file
367
packages/agent-core-v2/scripts/gen-config-manifest.mts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* Generates `docs/config-manifest.toml` — the single place to see every config
|
||||
* section registered via `registerConfigSection(...)` plus every effective
|
||||
* overlay registered via `registerConfigOverlay(...)`.
|
||||
*
|
||||
* Two passes:
|
||||
* 1. Static scan of `src/**` maps each registered section domain (and each
|
||||
* overlay) to the source file that registers it — the "owner".
|
||||
* 2. Runtime pass imports `src/index.ts` ("import = register") and drains the
|
||||
* module-level contributions, capturing defaults, env bindings, and the
|
||||
* registered hooks exactly as the running process sees them.
|
||||
*
|
||||
* The output is TOML in the on-disk shape (snake_case keys): one `[table]` per
|
||||
* section, uncommented assignments for registered defaults, and commented
|
||||
* `# field: type` lines for the remaining schema fields.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest # write the file
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest --check # freshness check (CI-style)
|
||||
*
|
||||
* Freshness is also enforced by `test/app/config/configManifest.test.ts`.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { getConfigOverlayContributions } from '#/app/config/configOverlayContributions';
|
||||
import type { ConfigSectionContribution } from '#/app/config/configSectionContributions';
|
||||
import { getConfigSectionContributions } from '#/app/config/configSectionContributions';
|
||||
import { camelToSnake } from '#/app/config/toml';
|
||||
|
||||
import {
|
||||
asJsonSchema,
|
||||
describeType,
|
||||
isRecord,
|
||||
resolveRef,
|
||||
toJsonSchema,
|
||||
truncate,
|
||||
type JsonSchema,
|
||||
} from './lib/jsonSchema.mts';
|
||||
|
||||
const PKG = join(import.meta.dirname, '..');
|
||||
const SRC = join(PKG, 'src');
|
||||
export const MANIFEST_PATH = join(PKG, 'docs', 'config-manifest.toml');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static pass — domain/overlay → owner file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (entry.endsWith('.ts')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function constStringValue(source: string, ident: string): string | undefined {
|
||||
const re = new RegExp(`(?:export\\s+)?const\\s+${ident}\\s*(?::[^=;]+)?=\\s*'([^']+)'`);
|
||||
return re.exec(source)?.[1];
|
||||
}
|
||||
|
||||
/** domain key → owner file (relative to the package root). */
|
||||
function scanSectionOwners(): Map<string, string> {
|
||||
const owners = new Map<string, string>();
|
||||
for (const file of walk(SRC)) {
|
||||
const source = readFileSync(file, 'utf-8');
|
||||
if (!source.includes('registerConfigSection(')) continue;
|
||||
for (const match of source.matchAll(/registerConfigSection\(\s*(?:'([^']+)'|([A-Za-z0-9_$]+))/g)) {
|
||||
const ident = match[2];
|
||||
const domain = match[1] ?? (ident === undefined ? undefined : constStringValue(source, ident));
|
||||
if (domain !== undefined) owners.set(domain, relative(PKG, file));
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
/** overlay variable name → owner file (relative to the package root). */
|
||||
function scanOverlayOwners(): Map<string, string> {
|
||||
const owners = new Map<string, string>();
|
||||
for (const file of walk(SRC)) {
|
||||
// Skip the collector module itself — its `registerConfigOverlay(overlay)`
|
||||
// function signature is not a registration.
|
||||
if (file.endsWith('configOverlayContributions.ts')) continue;
|
||||
const source = readFileSync(file, 'utf-8');
|
||||
if (!source.includes('registerConfigOverlay(')) continue;
|
||||
for (const match of source.matchAll(/registerConfigOverlay\(\s*([A-Za-z0-9_$]+)/g)) {
|
||||
const ident = match[1];
|
||||
if (ident !== undefined) owners.set(ident, relative(PKG, file));
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOML-like rendering helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Serialize a small JSON value as an inline TOML value. */
|
||||
function toTomlValue(value: unknown): string {
|
||||
if (typeof value === 'string') return JSON.stringify(value);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return `[${value.map(toTomlValue).join(', ')}]`;
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value).map(([k, v]) => `${camelToSnake(k)} = ${toTomlValue(v)}`);
|
||||
return `{ ${entries.join(', ')} }`;
|
||||
}
|
||||
return '""';
|
||||
}
|
||||
|
||||
interface EnvRow {
|
||||
readonly field: string;
|
||||
readonly env: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
/** Property access shape of an `EnvBinding` object (avoids index-signature access). */
|
||||
interface EnvBindingFields {
|
||||
readonly env?: unknown;
|
||||
readonly parse?: unknown;
|
||||
readonly default?: unknown;
|
||||
}
|
||||
|
||||
function flattenEnvBindings(bindings: unknown, path: string[] = []): EnvRow[] {
|
||||
if (typeof bindings === 'string') {
|
||||
return [{ field: path.join('.'), env: bindings, detail: '' }];
|
||||
}
|
||||
if (!isRecord(bindings)) return [];
|
||||
const binding = bindings as EnvBindingFields;
|
||||
if (typeof binding.env === 'string') {
|
||||
const detail: string[] = [];
|
||||
if (binding.parse !== undefined) detail.push('custom parse');
|
||||
if (binding.default !== undefined) detail.push(`default ${JSON.stringify(binding.default)}`);
|
||||
return [{ field: path.join('.'), env: binding.env, detail: detail.join('; ') }];
|
||||
}
|
||||
return Object.entries(bindings).flatMap(([key, value]) => flattenEnvBindings(value, [...path, key]));
|
||||
}
|
||||
|
||||
function snakePath(field: string): string {
|
||||
return field.split('.').map(camelToSnake).join('.');
|
||||
}
|
||||
|
||||
const RULE = `# ${'#'.repeat(74)}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `# field: type (default: x)` comment lines for an object schema's properties. */
|
||||
function renderFieldComments(
|
||||
properties: Record<string, unknown>,
|
||||
root: JsonSchema,
|
||||
indent: string,
|
||||
depth = 0,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const [name, prop] of Object.entries(properties)) {
|
||||
const resolved = resolveRef(prop, root);
|
||||
const propDefault = asJsonSchema(resolved)?.default;
|
||||
const defNote = propDefault !== undefined ? ` (default: ${JSON.stringify(propDefault)})` : '';
|
||||
lines.push(`${indent}# ${camelToSnake(name)}: ${describeType(resolved)}${defNote}`);
|
||||
// Expand nested object fields one level at a time (depth-capped so a
|
||||
// recursive $ref cannot loop).
|
||||
const subProps = asJsonSchema(resolved)?.properties;
|
||||
if (depth < 3 && isRecord(subProps) && Object.keys(subProps).length > 0) {
|
||||
lines.push(...renderFieldComments(subProps, root, `${indent} `, depth + 1));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderBody(section: ConfigSectionContribution): string[] {
|
||||
const { domain, schema, options } = section;
|
||||
const key = camelToSnake(domain);
|
||||
const jsonSchema = schema === undefined ? undefined : toJsonSchema(schema);
|
||||
|
||||
if (jsonSchema === undefined) {
|
||||
// No schema (passthrough) or a schema that JSON Schema cannot represent.
|
||||
if (isRecord(options.defaultValue)) {
|
||||
return [
|
||||
`[${key}]`,
|
||||
`# (${schema === undefined ? 'no schema — passthrough' : 'schema uses transforms'}; fields below come from the registered default)`,
|
||||
...Object.entries(options.defaultValue).map(
|
||||
([k, v]) => `${camelToSnake(k)} = ${truncate(toTomlValue(v))}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
if (options.defaultValue !== undefined) {
|
||||
return [`${key} = ${truncate(toTomlValue(options.defaultValue))}`];
|
||||
}
|
||||
return [`[${key}]`, `# (${schema === undefined ? 'no schema — passthrough' : 'schema uses transforms; see the owner file'})`];
|
||||
}
|
||||
|
||||
// Object with named fields.
|
||||
if (isRecord(jsonSchema.properties) && Object.keys(jsonSchema.properties).length > 0) {
|
||||
const defaults = isRecord(options.defaultValue) ? options.defaultValue : {};
|
||||
const lines = [`[${key}]`];
|
||||
for (const [name, prop] of Object.entries(jsonSchema.properties)) {
|
||||
const fieldKey = camelToSnake(name);
|
||||
if (defaults[name] !== undefined) {
|
||||
lines.push(`${fieldKey} = ${truncate(toTomlValue(defaults[name]))}`);
|
||||
continue;
|
||||
}
|
||||
// A nested object field is an on-disk sub-table (`[section.field]`) —
|
||||
// render its own fields instead of a flat `field: object` comment.
|
||||
const resolved = resolveRef(prop, jsonSchema);
|
||||
const subProps = asJsonSchema(resolved)?.properties;
|
||||
if (isRecord(subProps) && Object.keys(subProps).length > 0) {
|
||||
lines.push('');
|
||||
lines.push(`# [${key}.${fieldKey}]`);
|
||||
lines.push(...renderFieldComments(subProps, jsonSchema, ' '));
|
||||
continue;
|
||||
}
|
||||
// An array-of-objects field carries its element fields inline.
|
||||
const itemProps = asJsonSchema(
|
||||
resolveRef(asJsonSchema(resolved)?.items, jsonSchema),
|
||||
)?.properties;
|
||||
if (isRecord(itemProps) && Object.keys(itemProps).length > 0) {
|
||||
lines.push(`# ${fieldKey}: object[] — one entry per item:`);
|
||||
lines.push(...renderFieldComments(itemProps, jsonSchema, ' '));
|
||||
continue;
|
||||
}
|
||||
lines.push(...renderFieldComments({ [name]: prop }, jsonSchema, ''));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Record section — one sub-table per entry.
|
||||
if (jsonSchema.additionalProperties !== undefined) {
|
||||
const valueSchema = resolveRef(jsonSchema.additionalProperties, jsonSchema);
|
||||
const valueProps = asJsonSchema(valueSchema)?.properties;
|
||||
const lines = [`[${key}]`];
|
||||
if (isRecord(valueProps) && Object.keys(valueProps).length > 0) {
|
||||
lines.push('');
|
||||
lines.push(`# one [${key}."<name>"] table per entry:`);
|
||||
lines.push(`# [${key}."<name>"]`);
|
||||
lines.push(...renderFieldComments(valueProps, jsonSchema, ' '));
|
||||
} else {
|
||||
lines.push(`# <name>: ${describeType(valueSchema)}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Array-of-tables section — one `[[section]]` entry per element. There is
|
||||
// no `[section]` parent table in TOML, so the whole shape stays commented;
|
||||
// emitting a bare `[${key}]` header would parse as a plain table, which
|
||||
// array sections (e.g. `hooks`) reject on load.
|
||||
if (jsonSchema.type === 'array') {
|
||||
const itemProps = asJsonSchema(resolveRef(jsonSchema.items, jsonSchema))?.properties;
|
||||
if (isRecord(itemProps) && Object.keys(itemProps).length > 0) {
|
||||
return [
|
||||
`# one [[${key}]] table per entry:`,
|
||||
`# [[${key}]]`,
|
||||
...renderFieldComments(itemProps, jsonSchema, ' '),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Scalar / array section — a plain top-level key.
|
||||
if (options.defaultValue !== undefined) {
|
||||
return [`${key} = ${truncate(toTomlValue(options.defaultValue))}`];
|
||||
}
|
||||
return [`# ${key}: ${describeType(jsonSchema)}`];
|
||||
}
|
||||
|
||||
function renderSection(section: ConfigSectionContribution, owner: string | undefined): string[] {
|
||||
const { domain, options } = section;
|
||||
const key = camelToSnake(domain);
|
||||
const lines: string[] = [RULE];
|
||||
lines.push(`# ${domain}${key === domain ? '' : ` (config.toml: ${key})`}`);
|
||||
lines.push(`# owner: ${owner ?? '(unresolved)'}`);
|
||||
lines.push(`# scope: ${options.scope ?? 'core'}`);
|
||||
const hooks: string[] = [];
|
||||
if (options.merge !== undefined) hooks.push('custom merge');
|
||||
if (options.fromToml !== undefined) hooks.push('custom fromToml');
|
||||
if (options.toToml !== undefined) hooks.push('custom toToml');
|
||||
if (options.stripEnv !== undefined) hooks.push('stripEnv');
|
||||
if (hooks.length > 0) lines.push(`# hooks: ${hooks.join(' · ')}`);
|
||||
const envRows = flattenEnvBindings(options.env);
|
||||
if (envRows.length > 0) {
|
||||
lines.push('# env:');
|
||||
for (const row of envRows) {
|
||||
lines.push(`# ${snakePath(row.field)} <- ${row.env}${row.detail === '' ? '' : ` (${row.detail})`}`);
|
||||
}
|
||||
}
|
||||
lines.push(RULE);
|
||||
lines.push('');
|
||||
lines.push(...renderBody(section));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildConfigManifest(): Promise<string> {
|
||||
// "import = register": loading the package root fills the contribution bags.
|
||||
await import('../src/index.ts');
|
||||
const sections = getConfigSectionContributions().toSorted((a, b) =>
|
||||
a.domain.localeCompare(b.domain),
|
||||
);
|
||||
const overlays = getConfigOverlayContributions();
|
||||
const sectionOwners = scanSectionOwners();
|
||||
const overlayOwners = scanOverlayOwners();
|
||||
|
||||
const out: string[] = [
|
||||
'# Config Section Manifest',
|
||||
'#',
|
||||
'# Generated by scripts/gen-config-manifest.mts — do not edit by hand.',
|
||||
'# Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest',
|
||||
'#',
|
||||
'# One [table] per registered config section, in the on-disk config.toml shape',
|
||||
'# (snake_case keys). Un-commented assignments are registered defaults;',
|
||||
'# commented "# field: type" lines describe the remaining schema fields.',
|
||||
'# Values resolve as: default -> config.toml -> env overlay -> memory.',
|
||||
'',
|
||||
`# Index (${sections.length} sections · ${overlays.length} overlay(s))`,
|
||||
];
|
||||
const width = Math.max(...sections.map((s) => s.domain.length));
|
||||
for (const { domain } of sections) {
|
||||
out.push(`# ${domain.padEnd(width)} ${sectionOwners.get(domain) ?? '(unresolved)'}`);
|
||||
}
|
||||
for (const [ident, file] of overlayOwners) {
|
||||
out.push(`# ${'(overlay) ' + ident} ${file}`);
|
||||
}
|
||||
out.push('');
|
||||
|
||||
for (const section of sections) {
|
||||
out.push(...renderSection(section, sectionOwners.get(section.domain)));
|
||||
out.push('');
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const check = process.argv.includes('--check');
|
||||
const manifest = await buildConfigManifest();
|
||||
if (check) {
|
||||
let current: string | undefined;
|
||||
try {
|
||||
current = readFileSync(MANIFEST_PATH, 'utf-8');
|
||||
} catch {
|
||||
current = undefined;
|
||||
}
|
||||
if (current !== manifest) {
|
||||
console.error(
|
||||
`[gen-config-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` +
|
||||
'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[gen-config-manifest] up to date');
|
||||
return;
|
||||
}
|
||||
writeFileSync(MANIFEST_PATH, manifest);
|
||||
console.log(`[gen-config-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
973
packages/agent-core-v2/scripts/gen-wire-manifest.mts
Normal file
973
packages/agent-core-v2/scripts/gen-wire-manifest.mts
Normal file
|
|
@ -0,0 +1,973 @@
|
|||
/**
|
||||
* Generates `docs/wire-manifest.d.ts` — the single place to see every wire
|
||||
* record type registered via `defineOp(...)`.
|
||||
*
|
||||
* Two passes:
|
||||
* 1. Static scan of `src/**` maps each op type to the source file that
|
||||
* defines it — the "owner" — and collects the migration chain from
|
||||
* `src/wire/migration/v*.ts`.
|
||||
* 2. Runtime pass imports `src/index.ts` plus every op module found in the
|
||||
* static pass ("import = register") and drains `OP_REGISTRY`, capturing
|
||||
* the owning model, the persist policy, `toEvent`, and the payload schema
|
||||
* exactly as the running process sees them.
|
||||
*
|
||||
* The output is a `.d.ts` — one payload declaration per record type, with a
|
||||
* `WirePayloadMap` from record type to declaration — using real TypeScript
|
||||
* type syntax for the sketches.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest # write the file
|
||||
* pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest --check # freshness check (CI-style)
|
||||
*
|
||||
* Freshness is also enforced by `test/wire/wireManifest.test.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { MODEL_CROSS_REDUCERS } from '#/wire/model';
|
||||
import { OP_REGISTRY } from '#/wire/op';
|
||||
|
||||
import {
|
||||
asJsonSchema,
|
||||
describeType,
|
||||
isRecord,
|
||||
resolveRef,
|
||||
toJsonSchema,
|
||||
truncate,
|
||||
type JsonSchema,
|
||||
} from './lib/jsonSchema.mts';
|
||||
|
||||
const PKG = join(import.meta.dirname, '..');
|
||||
const SRC = join(PKG, 'src');
|
||||
export const MANIFEST_PATH = join(PKG, 'docs', 'wire-manifest.d.ts');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static pass — op type → owner file; migration chain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (entry.endsWith('.ts')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** op type → owner file (relative to the package root). */
|
||||
function scanOpOwners(): { owners: Map<string, string>; opFiles: string[] } {
|
||||
const owners = new Map<string, string>();
|
||||
const opFiles: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const source = readFileSync(file, 'utf-8');
|
||||
if (!source.includes('defineOp(')) continue;
|
||||
const matches = [...source.matchAll(/defineOp\(\s*'([^']+)'/g)];
|
||||
if (matches.length === 0) continue;
|
||||
opFiles.push(file);
|
||||
for (const match of matches) {
|
||||
const type = match[1];
|
||||
if (type !== undefined) owners.set(type, relative(PKG, file));
|
||||
}
|
||||
}
|
||||
return { owners, opFiles };
|
||||
}
|
||||
|
||||
/** `1.0 -> 1.1 -> ...` chain read from the `src/wire/migration/v*.ts` files. */
|
||||
function scanMigrationChain(): string {
|
||||
const dir = join(SRC, 'wire', 'migration');
|
||||
const pairs: { source: string; target: string }[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (!/^v[\d.]+\.ts$/.test(entry)) continue;
|
||||
const source = readFileSync(join(dir, entry), 'utf-8');
|
||||
const sourceVersion = /sourceVersion:\s*'([^']+)'/.exec(source)?.[1];
|
||||
const targetVersion = /targetVersion:\s*'([^']+)'/.exec(source)?.[1];
|
||||
if (sourceVersion !== undefined && targetVersion !== undefined) {
|
||||
pairs.push({ source: sourceVersion, target: targetVersion });
|
||||
}
|
||||
}
|
||||
pairs.sort((a, b) => a.source.localeCompare(b.source, undefined, { numeric: true }));
|
||||
const chain = pairs.flatMap((p, i) => (i === 0 ? [p.source, p.target] : [p.target]));
|
||||
return chain.join(' -> ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload sketch
|
||||
//
|
||||
// A Sketch is a small tree: strings are one-line type annotations, dicts are
|
||||
// object shapes, and a one-element array marks an array-of shape. The d.ts
|
||||
// renderer below turns the tree into real TypeScript syntax.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SketchDict = { [key: string]: Sketch };
|
||||
type Sketch = string | SketchDict | [Sketch];
|
||||
|
||||
/** First key of a dict produced by expanding a named type. */
|
||||
const TYPE_KEY = '_type';
|
||||
/** Marker key rendered as a `// …` comment when a field list is capped. */
|
||||
const MORE_KEY = '…';
|
||||
|
||||
/** Compact one-line rendering of a Sketch (used inside unions/intersections). */
|
||||
function stringifySketch(sketch: Sketch): string {
|
||||
if (typeof sketch === 'string') return sketch;
|
||||
if (Array.isArray(sketch)) {
|
||||
const inner = stringifySketch(sketch[0]);
|
||||
return inner.includes('|') ? `(${inner})[]` : `${inner}[]`;
|
||||
}
|
||||
return `{ ${Object.entries(sketch)
|
||||
.map(([k, v]) => `${k}: ${stringifySketch(v)}`)
|
||||
.join(', ')} }`;
|
||||
}
|
||||
|
||||
/** Build a Sketch tree from a zod JSON-schema projection. */
|
||||
function sketchFromJsonSchema(schema: unknown, root: JsonSchema, depth: number): Sketch {
|
||||
const resolved = resolveRef(schema, root);
|
||||
const s = asJsonSchema(resolved);
|
||||
if (s !== undefined && depth < 4) {
|
||||
if (isRecord(s.properties) && Object.keys(s.properties).length > 0) {
|
||||
const required = new Set(Array.isArray(s.required) ? s.required : []);
|
||||
const dict: SketchDict = {};
|
||||
for (const [name, prop] of Object.entries(s.properties)) {
|
||||
dict[required.has(name) ? name : `${name}?`] = sketchFromJsonSchema(prop, root, depth + 1);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
if (s.type === 'array' && s.items !== undefined) {
|
||||
const inner = sketchFromJsonSchema(s.items, root, depth + 1);
|
||||
if (typeof inner !== 'string') return [inner];
|
||||
}
|
||||
}
|
||||
return describeType(resolved, tsQuote);
|
||||
}
|
||||
|
||||
/** Build the payload Sketch tree for one op (all three data paths converge). */
|
||||
function buildPayloadSketch(
|
||||
schema: unknown,
|
||||
staticSketch?: string | Map<string, Sketch>,
|
||||
): Sketch {
|
||||
const jsonSchema = toJsonSchema(schema);
|
||||
if (jsonSchema === undefined) {
|
||||
if (typeof staticSketch === 'string') return staticSketch;
|
||||
if (staticSketch !== undefined && staticSketch.size > 0) {
|
||||
return Object.fromEntries(staticSketch);
|
||||
}
|
||||
return '(schema uses transforms; see the owner file)';
|
||||
}
|
||||
if (isRecord(jsonSchema.properties) && Object.keys(jsonSchema.properties).length > 0) {
|
||||
const required = new Set(Array.isArray(jsonSchema.required) ? jsonSchema.required : []);
|
||||
const dict: SketchDict = {};
|
||||
for (const [name, prop] of Object.entries(jsonSchema.properties)) {
|
||||
dict[required.has(name) ? name : `${name}?`] = sketchFromJsonSchema(prop, jsonSchema, 0);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
// An empty object schema (`z.object({})`) is a payload-less record.
|
||||
if (
|
||||
jsonSchema.type === 'object' &&
|
||||
(jsonSchema.additionalProperties === undefined || jsonSchema.additionalProperties === false)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return describeType(jsonSchema, tsQuote);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// d.ts rendering — Sketch tree → TypeScript declarations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pascalCase(name: string): string {
|
||||
return name
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => (part[0] ?? '').toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function tsFieldKey(key: string): string {
|
||||
return /^[$A-Z_a-z][$\w]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a one-line sketch annotation into a valid TS type expression.
|
||||
* Returns the type plus an optional doc note (the expanded type's name, or a
|
||||
* hoisted shared spread that cannot be expressed inline).
|
||||
*/
|
||||
function sketchStringToTs(text: string): { type: string; doc?: string } {
|
||||
let t = text.trim();
|
||||
const docs: string[] = [];
|
||||
const named = /^([A-Z][$\w]*) = ([\s\S]+)$/.exec(t);
|
||||
if (named?.[1] !== undefined && named[2] !== undefined) {
|
||||
docs.push(named[1]);
|
||||
t = named[2].trim();
|
||||
}
|
||||
// A hoisted shared spread (`...base & A | B`) becomes a doc note + variants.
|
||||
const spread = /^((?:\.\.\.[$\w]+(?: \+ )?)+) & ([\s\S]+)$/.exec(t);
|
||||
if (spread?.[1] !== undefined && spread[2] !== undefined) {
|
||||
docs.push(`shared base: ${spread[1]}`);
|
||||
t = spread[2].trim();
|
||||
}
|
||||
t = t.replaceAll(/union on [$\w]+: /g, '');
|
||||
t = t.replaceAll(/\brecord</g, 'Record<');
|
||||
t = t.replaceAll(/\binteger\b/g, 'number');
|
||||
return { type: t, doc: docs.length > 0 ? docs.join(' · ') : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Sketch as TS type-expression lines. The first line continues after
|
||||
* the field's `key: `; subsequent lines carry `indent`.
|
||||
*/
|
||||
function renderTsType(sketch: Sketch, indent: string): { doc?: string; lines: string[] } {
|
||||
if (typeof sketch === 'string') {
|
||||
const { type, doc } = sketchStringToTs(sketch);
|
||||
return { doc, lines: [type] };
|
||||
}
|
||||
if (Array.isArray(sketch)) {
|
||||
const inner = renderTsType(sketch[0], indent);
|
||||
const lines = [...inner.lines];
|
||||
lines[lines.length - 1] += '[]';
|
||||
return { doc: inner.doc, lines };
|
||||
}
|
||||
const doc = typeof sketch[TYPE_KEY] === 'string' ? sketch[TYPE_KEY] : undefined;
|
||||
const lines = ['{'];
|
||||
emitTsDict(lines, sketch, indent + ' ');
|
||||
lines.push(`${indent}}`);
|
||||
return { doc, lines };
|
||||
}
|
||||
|
||||
function emitTsDict(lines: string[], dict: SketchDict, indent: string): void {
|
||||
for (const [key, sketch] of Object.entries(dict)) {
|
||||
if (key === MORE_KEY) {
|
||||
lines.push(`${indent}// …`);
|
||||
continue;
|
||||
}
|
||||
if (key === TYPE_KEY) continue; // surfaces as the field's doc comment
|
||||
if (key.startsWith('...')) {
|
||||
lines.push(`${indent}// spread: ${key}`);
|
||||
continue;
|
||||
}
|
||||
const optional = key.endsWith('?');
|
||||
const fieldKey = tsFieldKey(optional ? key.slice(0, -1) : key);
|
||||
const { doc, lines: typeLines } = renderTsType(sketch, indent);
|
||||
if (doc !== undefined) lines.push(`${indent}/** ${doc} */`);
|
||||
lines.push(`${indent}${fieldKey}${optional ? '?' : ''}: ${typeLines[0]}${typeLines.length === 1 ? ';' : ''}`);
|
||||
if (typeLines.length > 1) {
|
||||
lines.push(...typeLines.slice(1, -1));
|
||||
lines.push(`${typeLines[typeLines.length - 1]};`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One record type's payload declaration (`interface` for objects, `type` otherwise). */
|
||||
function renderPayloadDecl(
|
||||
entry: { type: string; model: { name: string }; persist?: boolean; toEvent?: unknown },
|
||||
owner: string | undefined,
|
||||
flags: string[],
|
||||
sketch: Sketch,
|
||||
): string[] {
|
||||
const name = `${pascalCase(entry.type)}Payload`;
|
||||
const nameField = `_name: '${entry.type}';`;
|
||||
const header = [
|
||||
'/**',
|
||||
` * model: ${entry.model.name}${flags.length > 0 ? ` · ${flags.join(' · ')}` : ''}`,
|
||||
` * owner: ${owner ?? '(unresolved)'}`,
|
||||
];
|
||||
if (typeof sketch === 'string') {
|
||||
const { type, doc } = sketchStringToTs(sketch);
|
||||
if (type.startsWith('(')) {
|
||||
// Unrepresentable schema note — keep the declaration parseable.
|
||||
header.push(` * ${type.slice(1, -1)}`);
|
||||
header.push(' */');
|
||||
return [...header, `interface ${name} {\n ${nameField}\n}`, ''];
|
||||
}
|
||||
if (doc !== undefined) header.push(` * ${doc}`);
|
||||
header.push(' */');
|
||||
return [...header, `type ${name} = { ${nameField} } & (${type});`, ''];
|
||||
}
|
||||
if (Array.isArray(sketch)) {
|
||||
const inner = renderTsType(sketch[0], ' ');
|
||||
const lines = [...inner.lines];
|
||||
lines[lines.length - 1] += '[]';
|
||||
header.push(' */');
|
||||
if (lines.length === 1) {
|
||||
return [...header, `type ${name} = { ${nameField} } & (${lines[0]});`, ''];
|
||||
}
|
||||
return [
|
||||
...header,
|
||||
`type ${name} = { ${nameField} } & (${lines[0]}`,
|
||||
...lines.slice(1, -1),
|
||||
`${lines[lines.length - 1]});`,
|
||||
'',
|
||||
];
|
||||
}
|
||||
const payloadType = typeof sketch[TYPE_KEY] === 'string' ? sketch[TYPE_KEY] : undefined;
|
||||
if (payloadType !== undefined) header.push(` * payload type: ${payloadType}`);
|
||||
header.push(' */');
|
||||
const lines = [...header, `interface ${name} {`, ` ${nameField}`];
|
||||
emitTsDict(lines, sketch, ' ');
|
||||
lines.push('}', '');
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static payload fallback — sketch fields from source when the zod schema
|
||||
// cannot be projected to JSON Schema (payloads using `z.custom<T>()`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Find the index of the closer matching the opener at `start` (quotes-aware). */
|
||||
function matchDelimiter(source: string, start: number, open: string, close: string): number {
|
||||
let depth = 0;
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === '/' && source[i + 1] === '/') {
|
||||
i = source.indexOf('\n', i);
|
||||
if (i === -1) return -1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && source[i + 1] === '*') {
|
||||
i = source.indexOf('*/', i);
|
||||
if (i === -1) return -1;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
const quote = ch;
|
||||
i += 1;
|
||||
while (i < source.length && source[i] !== quote) {
|
||||
if (source[i] === '\\') i += 1;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === open) depth += 1;
|
||||
else if (ch === close) {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Split `body` into top-level parts on any of `separators` (quotes/nesting-aware). */
|
||||
function splitTopLevel(body: string, separators: readonly string[] = [',']): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let partStart = 0;
|
||||
const n = body.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const ch = body[i];
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
const quote = ch;
|
||||
i += 1;
|
||||
while (i < n && body[i] !== quote) {
|
||||
if (body[i] === '\\') i += 1;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '(' || ch === '[' || ch === '<') depth += 1;
|
||||
else if (ch === '}' || ch === ')' || ch === ']' || ch === '>') depth = Math.max(0, depth - 1);
|
||||
else if (ch !== undefined && depth === 0 && separators.includes(ch)) {
|
||||
parts.push(body.slice(partStart, i).trim());
|
||||
partStart = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(body.slice(partStart).trim());
|
||||
return parts.filter((p) => p !== '');
|
||||
}
|
||||
|
||||
/** Split an object literal's body into top-level `key: expr` fields. */
|
||||
function splitObjectFields(body: string): Map<string, string> {
|
||||
const fields = new Map<string, string>();
|
||||
for (const part of splitTopLevel(body)) {
|
||||
const keyMatch = /^([$\w]+|'[^']+'|"[^"]+")\s*:/.exec(part);
|
||||
if (keyMatch?.[1] !== undefined) {
|
||||
const key = keyMatch[1].replace(/^['"]|['"]$/g, '');
|
||||
fields.set(key, part.slice(keyMatch[0].length).trim());
|
||||
} else if (part.startsWith('...')) {
|
||||
fields.set(part, '');
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Extract the body of the first balanced `{...}` in `text` starting at `braceIndex`. */
|
||||
function objectBody(text: string, braceIndex: number): string | undefined {
|
||||
const end = matchDelimiter(text, braceIndex, '{', '}');
|
||||
return end === -1 ? undefined : text.slice(braceIndex + 1, end);
|
||||
}
|
||||
|
||||
/** Read one expression from `start` up to the top-level `;` that ends the statement. */
|
||||
function readExpression(source: string, start: number): string {
|
||||
let depth = 0;
|
||||
const n = source.length;
|
||||
for (let i = start; i < n; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
const quote = ch;
|
||||
i += 1;
|
||||
while (i < n && source[i] !== quote) {
|
||||
if (source[i] === '\\') i += 1;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '(' || ch === '[' || ch === '<') depth += 1;
|
||||
else if (ch === '}' || ch === ')' || ch === ']' || ch === '>') depth = Math.max(0, depth - 1);
|
||||
else if (ch === ';' && depth === 0) return source.slice(start, i);
|
||||
}
|
||||
return source.slice(start);
|
||||
}
|
||||
|
||||
/** Quote a string literal TS-style (single quotes) so sketches need no JSON escapes. */
|
||||
function tsQuote(raw: string): string {
|
||||
return raw.includes("'") ? JSON.stringify(raw) : `'${raw}'`;
|
||||
}
|
||||
|
||||
/** Resolve a `schema:` expression to an object-literal body, following local consts. */
|
||||
function resolveSchemaLiteral(expr: string, source: string, depth = 0): string | undefined {
|
||||
if (depth > 2) return undefined;
|
||||
// z.object({ ... }) / z.strictObject({ ... }) — inline literal.
|
||||
const inline = /^z\.\w*[oO]bject\s*\(/.exec(expr);
|
||||
if (inline !== null) {
|
||||
const rest = expr.slice(inline[0].length).trimStart();
|
||||
if (rest.startsWith('{')) return objectBody(rest, 0);
|
||||
// z.object(SHAPE_CONST) — look up the local shape const.
|
||||
const shapeName = /^([$\w]+)/.exec(rest)?.[1];
|
||||
if (shapeName !== undefined) {
|
||||
const constRe = new RegExp(`const\\s+${shapeName}\\s*(?::[^=;]+)?=\\s*\\{`);
|
||||
const m = constRe.exec(source);
|
||||
if (m !== null) return objectBody(source, m.index + m[0].length - 1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// schema: SOME_CONST — follow `const X = z.object(...)` in the same file.
|
||||
const ident = /^([$\w]+)$/.exec(expr.trim())?.[1];
|
||||
if (ident !== undefined) {
|
||||
const constRe = new RegExp(`const\\s+${ident}\\s*(?::[^=;]+)?=\\s*`);
|
||||
const m = constRe.exec(source);
|
||||
if (m !== null) {
|
||||
const rhs = readExpression(source, m.index + m[0].length).trim();
|
||||
return resolveSchemaLiteral(rhs, source, depth + 1);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TS type summarizer — expand `z.custom<T>()` type names into readable sketches
|
||||
// by resolving the alias (or interface) across local definitions, imports, and
|
||||
// re-exports. Discriminated unions collapse to `union on type: "a" | "b"`.
|
||||
// Resolution work is bounded by a per-expansion step budget.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Budget {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
function spend(budget: Budget): boolean {
|
||||
if (budget.remaining <= 0) return false;
|
||||
budget.remaining -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
const TS_BUDGET = (): Budget => ({ remaining: 24 });
|
||||
|
||||
const _fileCache = new Map<string, string>();
|
||||
|
||||
function readCached(file: string): string {
|
||||
let text = _fileCache.get(file);
|
||||
if (text === undefined) {
|
||||
text = readFileSync(file, 'utf-8');
|
||||
_fileCache.set(file, text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
interface TsField {
|
||||
readonly type: string;
|
||||
readonly optional: boolean;
|
||||
}
|
||||
|
||||
/** Split a TS object type literal body into fields (separators: `;` / `,`). */
|
||||
function splitTsTypeFields(body: string): Map<string, TsField> {
|
||||
const fields = new Map<string, TsField>();
|
||||
for (const part of splitTopLevel(body, [';', ','])) {
|
||||
const m = /^(?:readonly\s+)?([$\w]+|'[^']+'|"[^"]+")\s*(\?)?\s*:\s*(.+)$/.exec(part);
|
||||
if (m?.[1] !== undefined && m[3] !== undefined) {
|
||||
fields.set(m[1].replace(/^['"]|['"]$/g, ''), {
|
||||
type: m[3].trim(),
|
||||
optional: m[2] !== undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
const TS_PRIMITIVES = new Set(['string', 'number', 'boolean', 'unknown', 'any', 'null', 'undefined', 'void']);
|
||||
|
||||
function renderTsFields(
|
||||
fields: Map<string, TsField>,
|
||||
file: string,
|
||||
budget: Budget,
|
||||
charBudget: number,
|
||||
depth: number,
|
||||
): SketchDict {
|
||||
const dict: SketchDict = {};
|
||||
let count = 0;
|
||||
for (const [name, f] of fields) {
|
||||
if (count >= 8) {
|
||||
dict[MORE_KEY] = '…';
|
||||
break;
|
||||
}
|
||||
count += 1;
|
||||
dict[`${name}${f.optional ? '?' : ''}`] = summarizeTsTypeExpr(
|
||||
f.type,
|
||||
file,
|
||||
budget,
|
||||
Math.max(120, Math.floor(charBudget / 2)),
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/** Find a local `type X = ...` / `interface X {...}` definition's RHS text. */
|
||||
function findTsTypeDef(name: string, file: string): string | undefined {
|
||||
const source = readCached(file);
|
||||
const typeRe = new RegExp(`(?:export\\s+)?type\\s+${name}(?:<[^>;=]*>)?\\s*=\\s*`);
|
||||
const m = typeRe.exec(source);
|
||||
if (m !== null) return readExpression(source, m.index + m[0].length).trim();
|
||||
const ifaceRe = new RegExp(`(?:export\\s+)?interface\\s+${name}(?:<[^>]*>)?(?:\\s+extends[^{]+)?\\s*\\{`);
|
||||
const im = ifaceRe.exec(source);
|
||||
if (im !== null) {
|
||||
const body = objectBody(source, im.index + im[0].length - 1);
|
||||
if (body !== undefined) return `{ ${body} }`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Find the module specifier a name is imported (or named-re-exported) from. */
|
||||
function findImportSource(file: string, name: string): string | undefined {
|
||||
const source = readCached(file);
|
||||
const re = /(?:import|export)\s+(?:type\s+)?\{([^}]+)\}\s*from\s*'([^']+)'/g;
|
||||
for (const m of source.matchAll(re)) {
|
||||
for (const part of m[1]!.split(',')) {
|
||||
const named = /^(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?$/.exec(part.trim());
|
||||
if (named === null) continue;
|
||||
if ((named[2] ?? named[1]) === name) return m[2];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveModuleFile(fromFile: string, specifier: string): string | undefined {
|
||||
let base: string;
|
||||
if (specifier.startsWith('#/')) base = join(SRC, specifier.slice(2));
|
||||
else if (specifier.startsWith('.')) base = join(dirname(fromFile), specifier);
|
||||
else return undefined;
|
||||
for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function summarizeTsUnion(
|
||||
members: string[],
|
||||
file: string,
|
||||
budget: Budget,
|
||||
charBudget: number,
|
||||
depth: number,
|
||||
): string {
|
||||
// Resolve member idents one level so alias unions (ContextMessage = A | B | C)
|
||||
// still expose their object shapes.
|
||||
const resolved = members.map((m) => {
|
||||
const t = m.trim();
|
||||
if (/^[$\w]+$/.test(t)) {
|
||||
const def = findTsTypeDef(t, file);
|
||||
if (def !== undefined) return def;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
const bodies = resolved.map((m) => (m.trim().startsWith('{') ? objectBody(m.trim(), 0) : undefined));
|
||||
if (bodies.length > 0 && bodies.every((b) => b !== undefined)) {
|
||||
const fieldMaps = bodies.map((b) => splitTsTypeFields(b!));
|
||||
// Discriminated union: one field is a string literal in every member.
|
||||
for (const [name, info] of fieldMaps[0]!) {
|
||||
if (
|
||||
/^'[^']*'$/.test(info.type) &&
|
||||
fieldMaps.every((fm) => /^'[^']*'$/.test(fm.get(name)?.type ?? ''))
|
||||
) {
|
||||
const values = fieldMaps.map((fm) => tsQuote(fm.get(name)!.type.slice(1, -1)));
|
||||
return truncate(`union on ${name}: ${values.join(' | ')}`, charBudget);
|
||||
}
|
||||
}
|
||||
// Unions stay one-line strings; object members use the compact renderer.
|
||||
return truncate(
|
||||
fieldMaps
|
||||
.map((fm) => stringifySketch(renderTsFields(fm, file, budget, charBudget, depth + 1)))
|
||||
.join(' | '),
|
||||
charBudget * 2,
|
||||
);
|
||||
}
|
||||
return truncate(
|
||||
members
|
||||
.map((m) => stringifySketch(summarizeTsTypeExpr(m, file, budget, charBudget, depth + 1)))
|
||||
.join(' | '),
|
||||
charBudget * 2,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeTsTypeExpr(
|
||||
rhs: string,
|
||||
file: string,
|
||||
budget: Budget,
|
||||
charBudget = 480,
|
||||
depth = 0,
|
||||
): Sketch {
|
||||
let text = rhs.replaceAll(/\s+/g, ' ').trim();
|
||||
if (text.startsWith('readonly ')) text = text.slice('readonly '.length).trim();
|
||||
const literal = /^'([^']*)'$/.exec(text);
|
||||
if (literal?.[1] !== undefined) return tsQuote(literal[1]);
|
||||
if (TS_PRIMITIVES.has(text)) return text;
|
||||
if (text.endsWith('[]')) {
|
||||
const inner = summarizeTsTypeExpr(text.slice(0, -2), file, budget, charBudget, depth + 1);
|
||||
if (typeof inner !== 'string') return [inner];
|
||||
return inner.includes('|') ? `(${inner})[]` : `${inner}[]`;
|
||||
}
|
||||
const members = splitTopLevel(text, ['|']);
|
||||
if (members.length > 1) {
|
||||
return spend(budget)
|
||||
? summarizeTsUnion(members, file, budget, charBudget, depth)
|
||||
: truncate(text, 80);
|
||||
}
|
||||
const intersections = splitTopLevel(text, ['&']);
|
||||
if (intersections.length > 1) {
|
||||
if (!spend(budget)) return truncate(text, 80);
|
||||
const sides = intersections.map((m) => summarizeTsTypeExpr(m, file, budget, charBudget, depth + 1));
|
||||
// An intersection of object shapes merges into one dictionary.
|
||||
if (sides.every((side) => typeof side !== 'string' && !Array.isArray(side))) {
|
||||
return Object.assign({}, ...sides) as SketchDict;
|
||||
}
|
||||
return truncate(sides.map(stringifySketch).join(' & '), charBudget * 2);
|
||||
}
|
||||
if (text.startsWith('{')) {
|
||||
if (!spend(budget) || depth >= 4) return 'object';
|
||||
const body = objectBody(text, 0);
|
||||
if (body !== undefined) {
|
||||
return renderTsFields(splitTsTypeFields(body), file, budget, charBudget, depth);
|
||||
}
|
||||
}
|
||||
if (/^[$\w]+$/.test(text)) {
|
||||
const summary = summarizeTsType(text, file, budget);
|
||||
if (summary !== undefined) return summary;
|
||||
}
|
||||
return truncate(text, 80);
|
||||
}
|
||||
|
||||
/** Resolve a type name to a readable summary across aliases, imports, re-exports. */
|
||||
function summarizeTsType(name: string, fromFile: string, budget: Budget): Sketch | undefined {
|
||||
if (!spend(budget)) return undefined;
|
||||
const def = findTsTypeDef(name, fromFile);
|
||||
if (def !== undefined) return summarizeTsTypeExpr(def, fromFile, budget);
|
||||
const specifier = findImportSource(fromFile, name);
|
||||
if (specifier !== undefined) {
|
||||
const target = resolveModuleFile(fromFile, specifier);
|
||||
if (target !== undefined) return summarizeTsType(name, target, budget);
|
||||
}
|
||||
for (const m of readCached(fromFile).matchAll(/export\s+\*\s+from\s*'([^']+)'/g)) {
|
||||
const target = m[1] === undefined ? undefined : resolveModuleFile(fromFile, m[1]);
|
||||
if (target === undefined) continue;
|
||||
const summary = summarizeTsType(name, target, budget);
|
||||
if (summary !== undefined) return summary;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a zod field expression as a Sketch, in the same notation the
|
||||
* JSON-Schema path produces (`string`, `'a' | 'b'`, `Foo[]`). `z.custom<T>()`
|
||||
* and bare type idents expand through the TS type summarizer — object shapes
|
||||
* become nested dicts (keyed with the type name under `_type`), everything
|
||||
* else stays a one-line string.
|
||||
*/
|
||||
function friendlyZodExpr(expr: string, ownerFile: string, depth = 0): Sketch {
|
||||
let text = expr.replaceAll(/\s+/g, ' ').trim();
|
||||
// Strip trailing modifiers the sketch does not mark.
|
||||
let stripped = true;
|
||||
while (stripped) {
|
||||
stripped = false;
|
||||
for (const suffix of ['.optional()', '.nullable()', '.nullish()', '.readonly()']) {
|
||||
if (text.endsWith(suffix)) {
|
||||
text = text.slice(0, -suffix.length).trim();
|
||||
stripped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const stringLiteral = /^'([^']*)'$/.exec(text);
|
||||
if (stringLiteral?.[1] !== undefined) return tsQuote(stringLiteral[1]);
|
||||
const custom = /^z\.custom<(.+)>\(\)$/.exec(text);
|
||||
if (custom?.[1] !== undefined) {
|
||||
const typeName = custom[1].trim();
|
||||
// Expand the TS type only at the top levels — nested fields keep the bare
|
||||
// type name so long union member sketches stay readable.
|
||||
if (depth > 1) return typeName;
|
||||
const summary = summarizeTsType(typeName, ownerFile, TS_BUDGET());
|
||||
if (summary === undefined) return typeName;
|
||||
if (typeof summary !== 'string' && !Array.isArray(summary)) {
|
||||
return { [TYPE_KEY]: typeName, ...summary };
|
||||
}
|
||||
return truncate(`${typeName} = ${stringifySketch(summary)}`, 1024);
|
||||
}
|
||||
if (/^z\.string\(\)$/.test(text)) return 'string';
|
||||
if (/^z\.number\(\)$/.test(text)) return 'number';
|
||||
if (/^z\.boolean\(\)$/.test(text)) return 'boolean';
|
||||
if (/^z\.(?:int|integer)\(\)$/.test(text)) return 'integer';
|
||||
const array = /^z\.array\((.+)\)$/.exec(text);
|
||||
if (array?.[1] !== undefined) {
|
||||
const inner = friendlyZodExpr(array[1], ownerFile, depth + 1);
|
||||
if (typeof inner !== 'string') return [inner];
|
||||
return `${inner}[]`;
|
||||
}
|
||||
const literal = /^z\.literal\((.+)\)$/.exec(text);
|
||||
if (literal?.[1] !== undefined) return friendlyZodExpr(literal[1], ownerFile, depth + 1);
|
||||
const union = /^z\.union\((.+)\)$/.exec(text);
|
||||
if (union?.[1] !== undefined) return friendlyZodUnion(union[1], ownerFile, depth);
|
||||
const enumMatch = /^z\.enum\((.+)\)$/.exec(text);
|
||||
if (enumMatch?.[1] !== undefined) {
|
||||
const body = enumMatch[1].trim().replace(/^\[/, '').replace(/\]$/, '');
|
||||
return splitTopLevel(body)
|
||||
.map((member) => stringifySketch(friendlyZodExpr(member, ownerFile, depth + 1)))
|
||||
.join(' | ');
|
||||
}
|
||||
const record = /^z\.record\((.+)\)$/.exec(text);
|
||||
if (record?.[1] !== undefined) {
|
||||
const parts = splitTopLevel(record[1]);
|
||||
if (parts.length === 2) {
|
||||
return `record<string, ${stringifySketch(friendlyZodExpr(parts[1]!, ownerFile, depth + 1))}>`;
|
||||
}
|
||||
}
|
||||
if (/^z\.\w*[oO]bject\(/.test(text)) {
|
||||
if (depth >= 3) return 'object';
|
||||
const body = resolveSchemaLiteral(text, readCached(ownerFile));
|
||||
if (body === undefined) return 'object';
|
||||
const dict: SketchDict = {};
|
||||
for (const [key, fieldExpr] of splitObjectFields(body)) {
|
||||
if (fieldExpr === '') {
|
||||
dict[key] = '(spread)';
|
||||
continue;
|
||||
}
|
||||
const optional = /\.(?:optional|nullish)\(\)$/.test(fieldExpr);
|
||||
dict[`${key}${optional ? '?' : ''}`] = friendlyZodExpr(fieldExpr, ownerFile, depth + 1);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
const ident = /^([$\w]+)$/.exec(text)?.[1];
|
||||
if (ident !== undefined && depth < 4) {
|
||||
const source = readCached(ownerFile);
|
||||
const constRe = new RegExp(`const\\s+${ident}\\s*(?::[^=;]+)?=\\s*`);
|
||||
const m = constRe.exec(source);
|
||||
if (m !== null) {
|
||||
const rhs = readExpression(source, m.index + m[0].length).trim();
|
||||
return friendlyZodExpr(rhs, ownerFile, depth + 1);
|
||||
}
|
||||
if (depth <= 1) {
|
||||
const summary = summarizeTsType(ident, ownerFile, TS_BUDGET());
|
||||
if (summary !== undefined) {
|
||||
if (typeof summary !== 'string' && !Array.isArray(summary)) {
|
||||
return { [TYPE_KEY]: ident, ...summary };
|
||||
}
|
||||
return truncate(`${ident} = ${stringifySketch(summary)}`, 320);
|
||||
}
|
||||
}
|
||||
}
|
||||
return truncate(text, 80);
|
||||
}
|
||||
|
||||
/** Sketch a `z.union([...])` body (one-line string); object members get field sketches. */
|
||||
function friendlyZodUnion(body: string, ownerFile: string, depth: number): string {
|
||||
const members = splitTopLevel(body.trim().replace(/^\[/, '').replace(/\]$/, ''));
|
||||
const source = readCached(ownerFile);
|
||||
const bodies = members.map((m) => resolveSchemaLiteral(m, source));
|
||||
if (members.length > 0 && bodies.every((b) => b !== undefined)) {
|
||||
const fieldMaps = bodies.map((b) => splitObjectFields(b!));
|
||||
// Hoist spreads shared by every member (`...base & { … } | { … }`).
|
||||
const spreadSets = fieldMaps.map((fm) => [...fm.keys()].filter((k) => fm.get(k) === ''));
|
||||
const commonSpreads = (spreadSets[0] ?? []).filter((s) =>
|
||||
spreadSets.every((set) => set.includes(s)),
|
||||
);
|
||||
const sketches = fieldMaps.map((fm) => {
|
||||
const dict: SketchDict = {};
|
||||
for (const [key, expr] of fm) {
|
||||
if (expr === '') continue;
|
||||
const optional = /\.(?:optional|nullish)\(\)$/.test(expr);
|
||||
dict[`${key}${optional ? '?' : ''}`] = friendlyZodExpr(expr, ownerFile, depth + 1);
|
||||
}
|
||||
return stringifySketch(dict);
|
||||
});
|
||||
const prefix = commonSpreads.length > 0 ? `${commonSpreads.join(' + ')} & ` : '';
|
||||
return truncate(`${prefix}${sketches.join(' | ')}`, 320);
|
||||
}
|
||||
return truncate(
|
||||
members.map((m) => stringifySketch(friendlyZodExpr(m, ownerFile, depth + 1))).join(' | '),
|
||||
320,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort payload sketch from the owner source for schemas that use
|
||||
* `z.custom` (not representable as JSON Schema). Returns a field map for
|
||||
* object payloads, a type string for whole-payload custom schemas, or
|
||||
* `undefined` when the source shape is not recognized.
|
||||
*/
|
||||
function sketchPayloadFromSource(
|
||||
ownerFile: string,
|
||||
type: string,
|
||||
): string | Map<string, Sketch> | undefined {
|
||||
const absFile = join(PKG, ownerFile);
|
||||
const source = readCached(absFile);
|
||||
const callRe = new RegExp(`defineOp\\(\\s*'${type.replaceAll('.', '\\.')}'\\s*,\\s*\\{`);
|
||||
const call = callRe.exec(source);
|
||||
if (call === null) return undefined;
|
||||
const optionsBody = objectBody(source, call.index + call[0].length - 1);
|
||||
if (optionsBody === undefined) return undefined;
|
||||
const schemaField = /(?:^|[,\n])\s*schema\s*:/.exec(optionsBody);
|
||||
if (schemaField === null) return undefined;
|
||||
const afterSchema = optionsBody.slice(schemaField.index + schemaField[0].length).trimStart();
|
||||
// The schema expression ends at the next top-level comma.
|
||||
const exprFields = splitObjectFields(`schema: ${afterSchema}`);
|
||||
const schemaExpr = exprFields.get('schema');
|
||||
if (schemaExpr === undefined) return undefined;
|
||||
const literal = resolveSchemaLiteral(schemaExpr, source);
|
||||
if (literal === undefined) {
|
||||
const sketch = friendlyZodExpr(schemaExpr, absFile);
|
||||
if (typeof sketch === 'string') return sketch;
|
||||
if (!Array.isArray(sketch)) return new Map(Object.entries(sketch));
|
||||
return stringifySketch(sketch);
|
||||
}
|
||||
const sketch = new Map<string, Sketch>();
|
||||
for (const [key, expr] of splitObjectFields(literal)) {
|
||||
if (expr === '') {
|
||||
sketch.set(key, '(spread)');
|
||||
continue;
|
||||
}
|
||||
const optional = /\.(?:optional|nullish)\(\)$/.test(expr);
|
||||
sketch.set(`${key}${optional ? '?' : ''}`, friendlyZodExpr(expr, absFile));
|
||||
}
|
||||
return sketch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildWireManifest(): Promise<string> {
|
||||
const { owners, opFiles } = scanOpOwners();
|
||||
// "import = register": loading the package root plus every op module found in
|
||||
// the static pass fills OP_REGISTRY, even for modules index.ts does not load.
|
||||
await import('../src/index.ts');
|
||||
for (const file of opFiles) {
|
||||
await import(relative(join(PKG, 'scripts'), file));
|
||||
}
|
||||
const { WIRE_PROTOCOL_VERSION } = (await import('#/wire/migration/migration')) as {
|
||||
WIRE_PROTOCOL_VERSION: string;
|
||||
};
|
||||
|
||||
const entries = [...OP_REGISTRY.values()].toSorted((a, b) => a.type.localeCompare(b.type));
|
||||
const migrationChain = scanMigrationChain();
|
||||
|
||||
const out: string[] = [
|
||||
'// Wire Protocol Manifest',
|
||||
'//',
|
||||
'// Generated by scripts/gen-wire-manifest.mts — do not edit by hand.',
|
||||
'// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest',
|
||||
'//',
|
||||
`// protocol_version: "${WIRE_PROTOCOL_VERSION}" (migrations: ${migrationChain})`,
|
||||
'//',
|
||||
'// One declaration per record type registered via defineOp(...) and drained from',
|
||||
'// the runtime OP_REGISTRY. Every payload declaration carries its record type in',
|
||||
'// a `_name` field. Payload sketches use TypeScript type syntax; when a',
|
||||
'// named type is expanded inline, its name appears as a doc comment',
|
||||
'// (`/** ContextMessage */`). Bare type names (ContentPart, ContextMessage, …)',
|
||||
'// refer to the real types in src/ — they are intentionally not resolved here.',
|
||||
'// `// …` marks a capped field list. On disk (wire.jsonl) the journal opens with',
|
||||
'// a metadata line {"type": "metadata", "protocol_version", "created_at"}; each',
|
||||
'// op record is {"type", ...payload, "time"} — object payloads spread at the',
|
||||
'// top level, scalar payloads nest under a "payload" key.',
|
||||
'//',
|
||||
'// Declaration flags: persisted (written to the journal; absent = transient),',
|
||||
'// toEvent (also publishes an IEventBus fact on live dispatch), blobs (the',
|
||||
'// owning model offloads inline media to blob storage), cross-reducers',
|
||||
'// (foreign models that also reduce this record on dispatch and replay).',
|
||||
'',
|
||||
`// Index (${entries.length} record types)`,
|
||||
];
|
||||
const width = Math.max(...entries.map((e) => e.type.length));
|
||||
const modelWidth = Math.max(...entries.map((e) => e.model.name.length));
|
||||
for (const entry of entries) {
|
||||
const flags = entry.persist === false ? 'transient' : 'persisted';
|
||||
out.push(
|
||||
`// ${entry.type.padEnd(width)} ${entry.model.name.padEnd(modelWidth)} ${flags} ${owners.get(entry.type) ?? '(unresolved)'}`,
|
||||
);
|
||||
}
|
||||
out.push('');
|
||||
const declNames: [string, string][] = [];
|
||||
for (const entry of entries) {
|
||||
const flags: string[] = [];
|
||||
if (entry.persist !== false) flags.push('persisted');
|
||||
if (entry.toEvent !== undefined) flags.push('toEvent');
|
||||
if (entry.model.blobs !== undefined) flags.push('blobs');
|
||||
const crossReducers = (MODEL_CROSS_REDUCERS.get(entry.type) ?? [])
|
||||
.map((r) => (r.model as { name: string }).name)
|
||||
.filter((name) => name !== entry.model.name);
|
||||
if (crossReducers.length > 0) flags.push(`cross-reducers: ${crossReducers.join(', ')}`);
|
||||
const owner = owners.get(entry.type);
|
||||
const staticSketch =
|
||||
owner === undefined ? undefined : sketchPayloadFromSource(owner, entry.type);
|
||||
const sketch = buildPayloadSketch(entry.schema as unknown, staticSketch);
|
||||
out.push(...renderPayloadDecl(entry, owner, flags, sketch));
|
||||
declNames.push([entry.type, `${pascalCase(entry.type)}Payload`]);
|
||||
}
|
||||
|
||||
// Record type → payload declaration map.
|
||||
out.push('/** Record type → payload sketch. */');
|
||||
out.push('interface WirePayloadMap {');
|
||||
for (const [type, declName] of declNames) {
|
||||
out.push(` ${JSON.stringify(type)}: ${declName};`);
|
||||
}
|
||||
out.push('}');
|
||||
out.push('');
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const check = process.argv.includes('--check');
|
||||
const manifest = await buildWireManifest();
|
||||
if (check) {
|
||||
let current: string | undefined;
|
||||
try {
|
||||
current = readFileSync(MANIFEST_PATH, 'utf-8');
|
||||
} catch {
|
||||
current = undefined;
|
||||
}
|
||||
if (current !== manifest) {
|
||||
console.error(
|
||||
`[gen-wire-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` +
|
||||
'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[gen-wire-manifest] up to date');
|
||||
return;
|
||||
}
|
||||
writeFileSync(MANIFEST_PATH, manifest);
|
||||
console.log(`[gen-wire-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
99
packages/agent-core-v2/scripts/lib/jsonSchema.mts
Normal file
99
packages/agent-core-v2/scripts/lib/jsonSchema.mts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Shared JSON-schema helpers for the manifest generators
|
||||
* (`gen-config-manifest.mts`, `gen-wire-manifest.mts`).
|
||||
*
|
||||
* Both generators drain runtime registries that carry zod schemas and render
|
||||
* field/type sketches from their JSON Schema projection.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function truncate(text: string, max = 100): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** Property access shape of a JSON Schema node (avoids index-signature access). */
|
||||
export interface JsonSchema {
|
||||
readonly $ref?: unknown;
|
||||
readonly $defs?: unknown;
|
||||
readonly const?: unknown;
|
||||
readonly enum?: unknown;
|
||||
readonly anyOf?: unknown;
|
||||
readonly oneOf?: unknown;
|
||||
readonly type?: unknown;
|
||||
readonly items?: unknown;
|
||||
readonly properties?: unknown;
|
||||
readonly required?: unknown;
|
||||
readonly additionalProperties?: unknown;
|
||||
readonly default?: unknown;
|
||||
}
|
||||
|
||||
export function asJsonSchema(value: unknown): JsonSchema | undefined {
|
||||
return isRecord(value) ? (value as JsonSchema) : undefined;
|
||||
}
|
||||
|
||||
/** Resolve a `#/$defs/<name>` reference against the root schema. */
|
||||
export function resolveRef(schema: unknown, root: JsonSchema): unknown {
|
||||
const s = asJsonSchema(schema);
|
||||
if (typeof s?.$ref === 'string' && s.$ref.startsWith('#/$defs/')) {
|
||||
const defs = asJsonSchema(root.$defs);
|
||||
const name = s.$ref.slice('#/$defs/'.length);
|
||||
if (defs !== undefined && isRecord(defs) && name in defs) {
|
||||
return (defs as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** One-line type description of a JSON Schema node (`"a" | "b"`, `Foo[]`, …). */
|
||||
export function describeType(
|
||||
schema: unknown,
|
||||
quoteString: (raw: string) => string = (s) => JSON.stringify(s),
|
||||
): string {
|
||||
const s = asJsonSchema(schema);
|
||||
if (s === undefined) return 'any';
|
||||
if (s.$ref !== undefined) {
|
||||
return typeof s.$ref === 'string' ? (s.$ref.split('/').pop() ?? 'any') : 'any';
|
||||
}
|
||||
if (s.const !== undefined) {
|
||||
return truncate(
|
||||
typeof s.const === 'string' ? quoteString(s.const) : JSON.stringify(s.const),
|
||||
40,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(s.enum)) {
|
||||
return s.enum
|
||||
.map((v) => (typeof v === 'string' ? quoteString(v) : JSON.stringify(v)))
|
||||
.join(' | ');
|
||||
}
|
||||
for (const combiner of ['anyOf', 'oneOf'] as const) {
|
||||
const subs = s[combiner];
|
||||
if (Array.isArray(subs)) return subs.map((sub) => describeType(sub, quoteString)).join(' | ');
|
||||
}
|
||||
if (s.type === 'array') return `${describeType(s.items, quoteString)}[]`;
|
||||
if (s.type === 'object') {
|
||||
// Named sub-tables (zod objects emit `additionalProperties: false`) are
|
||||
// rendered by the caller; only a schema-valued additionalProperties marks
|
||||
// a true record.
|
||||
if (isRecord(s.properties)) return 'object';
|
||||
if (isRecord(s.additionalProperties)) {
|
||||
return `record<string, ${describeType(s.additionalProperties, quoteString)}>`;
|
||||
}
|
||||
return 'object';
|
||||
}
|
||||
if (typeof s.type === 'string') return s.type;
|
||||
return 'any';
|
||||
}
|
||||
|
||||
/** Project a zod schema to JSON Schema; `undefined` when it uses transforms. */
|
||||
export function toJsonSchema(schema: unknown): JsonSchema | undefined {
|
||||
try {
|
||||
return z.toJSONSchema(schema as never) as JsonSchema;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Scenario: the checked-in config-section manifest matches the live registry.
|
||||
*
|
||||
* Rebuilds `docs/config-manifest.toml` from the actual `registerConfigSection` /
|
||||
* `registerConfigOverlay` contributions and fails when the file is stale.
|
||||
* Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildConfigManifest, MANIFEST_PATH } from '../../../scripts/gen-config-manifest.mts';
|
||||
|
||||
describe('config manifest', () => {
|
||||
it('docs/config-manifest.toml is up to date', async () => {
|
||||
const expected = await buildConfigManifest();
|
||||
const actual = readFileSync(MANIFEST_PATH, 'utf-8');
|
||||
expect(actual).toBe(expected);
|
||||
}, 60_000);
|
||||
});
|
||||
34
packages/agent-core-v2/test/wire/wireManifest.test.ts
Normal file
34
packages/agent-core-v2/test/wire/wireManifest.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Scenario: the checked-in wire-protocol manifest matches the live OP_REGISTRY
|
||||
* and parses as a valid TypeScript declaration file.
|
||||
*
|
||||
* Rebuilds `docs/wire-manifest.d.ts` from the actual `defineOp` registrations
|
||||
* and fails when the file is stale. Regenerate with
|
||||
* `pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Project } from 'ts-morph';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildWireManifest, MANIFEST_PATH } from '../../scripts/gen-wire-manifest.mts';
|
||||
|
||||
describe('wire manifest', () => {
|
||||
it('docs/wire-manifest.d.ts is up to date', async () => {
|
||||
const expected = await buildWireManifest();
|
||||
const actual = readFileSync(MANIFEST_PATH, 'utf-8');
|
||||
expect(actual).toBe(expected);
|
||||
}, 60_000);
|
||||
|
||||
it('docs/wire-manifest.d.ts parses as TypeScript', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile(
|
||||
'wire-manifest.d.ts',
|
||||
readFileSync(MANIFEST_PATH, 'utf-8'),
|
||||
);
|
||||
// `parseDiagnostics` is internal in the compiler typings but populated at runtime.
|
||||
const diagnostics = (sourceFile.compilerNode as { parseDiagnostics?: readonly unknown[] })
|
||||
.parseDiagnostics;
|
||||
expect(diagnostics ?? []).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue