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:
Haozhe 2026-07-23 11:17:10 +08:00 committed by GitHub
parent 188c0fcbf7
commit 5240b5c83c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2514 additions and 13 deletions

View file

@ -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.

View file

@ -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.

View 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[]

View 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;
}

View file

@ -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",

View 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();
}

View 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();
}

View 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;
}
}

View file

@ -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);
});

View 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([]);
});
});