typeclaw 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
package/src/config/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs'
|
|
1
|
+
import { accessSync, constants as fsConstants, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
4
4
|
|
|
@@ -6,6 +6,8 @@ import type { Model } from '@mariozechner/pi-ai'
|
|
|
6
6
|
import { z } from 'zod'
|
|
7
7
|
|
|
8
8
|
import { channelsSchema } from '@/channels/schema'
|
|
9
|
+
import { commitSystemFileSync } from '@/git/system-commit'
|
|
10
|
+
import { rolesConfigSchema } from '@/permissions/schema'
|
|
9
11
|
|
|
10
12
|
import {
|
|
11
13
|
DEFAULT_MODEL_REF,
|
|
@@ -75,7 +77,7 @@ const dockerfileFeatureSchema = z.union([
|
|
|
75
77
|
])
|
|
76
78
|
|
|
77
79
|
// `default(() => ({}))` paired with field-level defaults is the idiom that
|
|
78
|
-
// makes both `
|
|
80
|
+
// makes both `docker.file: {}` and an omitted `docker.file` key resolve to the
|
|
79
81
|
// SAME fully-populated object. A plain `.default({})` would short-circuit the
|
|
80
82
|
// inner field defaults when the key is omitted, leaving downstream code with
|
|
81
83
|
// `{ append: undefined, tmux: undefined, ... }` and a `lines.length` crash.
|
|
@@ -92,17 +94,42 @@ export const dockerfileSchema = dockerfileObjectSchema.default(() => dockerfileO
|
|
|
92
94
|
export type DockerfileConfig = z.infer<typeof dockerfileSchema>
|
|
93
95
|
export type DockerfileFeatureToggle = z.infer<typeof dockerfileFeatureSchema>
|
|
94
96
|
|
|
97
|
+
// The `docker` namespace nests Docker-related blocks under one top-level key
|
|
98
|
+
// so future extensions (e.g. `docker.compose`, `docker.buildArgs`) have a home
|
|
99
|
+
// without polluting the root. Today the only inhabitant is `docker.file`,
|
|
100
|
+
// which holds the same shape that used to live at top-level `dockerfile`.
|
|
101
|
+
// One-time migration (see `migrateLegacyConfigShape`) rewrites the old
|
|
102
|
+
// top-level key into the new path on first load.
|
|
103
|
+
export const dockerSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
file: dockerfileSchema,
|
|
106
|
+
})
|
|
107
|
+
.default(() => ({ file: dockerfileObjectSchema.parse({}) }))
|
|
108
|
+
|
|
109
|
+
export type DockerConfig = z.infer<typeof dockerSchema>
|
|
110
|
+
|
|
95
111
|
const gitignoreLineSchema = z.string().refine((line) => !/[\r\n]/.test(line), {
|
|
96
|
-
message: '
|
|
112
|
+
message: 'git.ignore.append entries must be single gitignore lines; split multiline patterns into array entries',
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const gitignoreObjectSchema = z.object({
|
|
116
|
+
append: z.array(gitignoreLineSchema).default([]),
|
|
97
117
|
})
|
|
98
118
|
|
|
99
|
-
export const gitignoreSchema =
|
|
119
|
+
export const gitignoreSchema = gitignoreObjectSchema.default(() => gitignoreObjectSchema.parse({}))
|
|
120
|
+
|
|
121
|
+
export type GitignoreConfig = z.infer<typeof gitignoreSchema>
|
|
122
|
+
|
|
123
|
+
// Same rationale as `dockerSchema`: a `git` namespace today carries `git.ignore`
|
|
124
|
+
// and leaves room for future siblings (e.g. `git.attributes`). The one-time
|
|
125
|
+
// migration also handles the rename of legacy top-level `gitignore`.
|
|
126
|
+
export const gitSchema = z
|
|
100
127
|
.object({
|
|
101
|
-
|
|
128
|
+
ignore: gitignoreSchema,
|
|
102
129
|
})
|
|
103
|
-
.default({
|
|
130
|
+
.default(() => ({ ignore: gitignoreObjectSchema.parse({}) }))
|
|
104
131
|
|
|
105
|
-
export type
|
|
132
|
+
export type GitConfig = z.infer<typeof gitSchema>
|
|
106
133
|
|
|
107
134
|
const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/
|
|
108
135
|
|
|
@@ -178,11 +205,41 @@ export const networkSchema = z
|
|
|
178
205
|
|
|
179
206
|
export type NetworkConfig = z.infer<typeof networkSchema>
|
|
180
207
|
|
|
208
|
+
// `models` is a map from profile name to a single curated model ref. The
|
|
209
|
+
// `default` profile is mandatory; every other profile is optional and falls
|
|
210
|
+
// back to `default` at resolution time (see `resolveProfile`).
|
|
211
|
+
//
|
|
212
|
+
// Profile names are open strings; the runtime recognizes a handful of
|
|
213
|
+
// well-known names by convention (`default`, `fast`, `deep`, `vision`) but
|
|
214
|
+
// any string is valid. Subagents may declare a static profile preference;
|
|
215
|
+
// callers may override per-spawn. Unknown profile names resolve to `default`
|
|
216
|
+
// with a one-time warning at session construction.
|
|
217
|
+
//
|
|
218
|
+
// The pre-multi-model schema had a single `model: KnownModelRef` at the top
|
|
219
|
+
// level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
|
|
220
|
+
// on first load (and writes the result back to disk + commits via
|
|
221
|
+
// `persistMigratedConfig`), so every downstream consumer sees the new shape.
|
|
222
|
+
export const modelsSchema = z
|
|
223
|
+
.record(z.string().min(1), z.enum(knownModelRefs))
|
|
224
|
+
.refine((m) => 'default' in m, { message: 'models.default is required' })
|
|
225
|
+
|
|
226
|
+
// Zod's `z.record(..., refine)` doesn't refine the inferred type — the inferred
|
|
227
|
+
// shape is `Record<string, KnownModelRef>` where every access is `T | undefined`.
|
|
228
|
+
// The runtime guarantee (the `refine` above) is that `default` is present, so
|
|
229
|
+
// we narrow the type here. Every consumer (auth.ts, agent/index.ts,
|
|
230
|
+
// resolveProfile) reads `models.default` on the hot path; without this
|
|
231
|
+
// narrowing they all have to assert or `?? throw`, which is noise around an
|
|
232
|
+
// invariant the schema already enforces.
|
|
233
|
+
export type Models = Record<string, KnownModelRef> & { default: KnownModelRef }
|
|
234
|
+
|
|
181
235
|
export const configSchema = z
|
|
182
236
|
.object({
|
|
183
237
|
$schema: z.string().optional(),
|
|
184
238
|
port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
|
|
185
|
-
|
|
239
|
+
// `default(() => ...)` ensures every parsed config has at least
|
|
240
|
+
// `models.default`. Direct `.default({ default: ... })` would short-circuit
|
|
241
|
+
// the refinement, so we lean on the lazy thunk form.
|
|
242
|
+
models: modelsSchema.default(() => ({ default: DEFAULT_MODEL_REF })) as unknown as z.ZodType<Models>,
|
|
186
243
|
// Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
|
|
187
244
|
// host paths exposed) without failing the whole config load. `typeclaw
|
|
188
245
|
// init` omits this field so users don't see noise for the empty case.
|
|
@@ -198,8 +255,9 @@ export const configSchema = z
|
|
|
198
255
|
channels: channelsSchema,
|
|
199
256
|
portForward: portForwardSchema,
|
|
200
257
|
network: networkSchema,
|
|
201
|
-
|
|
202
|
-
|
|
258
|
+
docker: dockerSchema,
|
|
259
|
+
git: gitSchema,
|
|
260
|
+
roles: rolesConfigSchema.optional(),
|
|
203
261
|
})
|
|
204
262
|
.catchall(z.unknown())
|
|
205
263
|
|
|
@@ -213,6 +271,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
|
|
|
213
271
|
return KNOWN_PROVIDERS[providerId].models[modelId as never]
|
|
214
272
|
}
|
|
215
273
|
|
|
274
|
+
// Resolves a profile name (e.g. `fast`, `deep`, `vision`) to a concrete model
|
|
275
|
+
// ref. Unknown profiles fall back to `default` so callers can pass through
|
|
276
|
+
// arbitrary subagent-declared or user-overridden strings without crashing.
|
|
277
|
+
// Returns the resolved ref plus whether it came from the requested profile or
|
|
278
|
+
// from the `default` fallback, so the caller can warn once per session
|
|
279
|
+
// instead of every prompt.
|
|
280
|
+
export type ResolvedProfile = {
|
|
281
|
+
ref: KnownModelRef
|
|
282
|
+
profile: string
|
|
283
|
+
fellBackToDefault: boolean
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
|
|
287
|
+
const requested = name ?? 'default'
|
|
288
|
+
const ref = models[requested]
|
|
289
|
+
if (ref !== undefined) {
|
|
290
|
+
return { ref, profile: requested, fellBackToDefault: false }
|
|
291
|
+
}
|
|
292
|
+
const fallback = models.default
|
|
293
|
+
return { ref: fallback, profile: 'default', fellBackToDefault: true }
|
|
294
|
+
}
|
|
295
|
+
|
|
216
296
|
// Resolves a mount's `path` field to an absolute host path, mirroring shell
|
|
217
297
|
// expansion rules: `~`/`~/...` → home dir, relative → resolved against `cwd`,
|
|
218
298
|
// absolute → unchanged. Single source of truth so validation and Docker arg
|
|
@@ -281,7 +361,7 @@ export type FieldEffect = 'applied' | 'restart-required' | 'ignored'
|
|
|
281
361
|
|
|
282
362
|
export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
283
363
|
$schema: 'ignored',
|
|
284
|
-
|
|
364
|
+
models: 'applied',
|
|
285
365
|
port: 'restart-required',
|
|
286
366
|
mounts: 'restart-required',
|
|
287
367
|
plugins: 'restart-required',
|
|
@@ -289,8 +369,18 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
|
289
369
|
channels: 'applied',
|
|
290
370
|
portForward: 'restart-required',
|
|
291
371
|
network: 'restart-required',
|
|
292
|
-
|
|
293
|
-
|
|
372
|
+
'docker.file': 'restart-required',
|
|
373
|
+
'git.ignore': 'restart-required',
|
|
374
|
+
// Split: `match` lists are reload-safe (typeclaw role claim, hand-edits
|
|
375
|
+
// adding/removing match rules apply without a container restart);
|
|
376
|
+
// `permissions` lists are restart-required (changing what a role can DO
|
|
377
|
+
// is a bigger deal than changing WHO fills it, and several consumers —
|
|
378
|
+
// plugin contexts, the security plugin guards — capture the permissions
|
|
379
|
+
// contract at boot). The diff machinery in diffConfig() understands
|
|
380
|
+
// `roles.match` and `roles.permissions` as virtual paths and compares
|
|
381
|
+
// the corresponding projections of the whole `roles` block.
|
|
382
|
+
'roles.match': 'applied',
|
|
383
|
+
'roles.permissions': 'restart-required',
|
|
294
384
|
}
|
|
295
385
|
|
|
296
386
|
// Stable JSON for value comparison. Fields are small JSON-shaped objects, so
|
|
@@ -329,6 +419,8 @@ function diffConfig(before: Config, after: Config): ConfigReloadDiff {
|
|
|
329
419
|
}
|
|
330
420
|
|
|
331
421
|
function readPath(obj: unknown, path: string): unknown {
|
|
422
|
+
if (path === 'roles.match') return projectRoles(obj, 'match')
|
|
423
|
+
if (path === 'roles.permissions') return projectRoles(obj, 'permissions')
|
|
332
424
|
let cur: unknown = obj
|
|
333
425
|
for (const part of path.split('.')) {
|
|
334
426
|
if (cur === null || cur === undefined) return undefined
|
|
@@ -337,6 +429,19 @@ function readPath(obj: unknown, path: string): unknown {
|
|
|
337
429
|
return cur
|
|
338
430
|
}
|
|
339
431
|
|
|
432
|
+
function projectRoles(obj: unknown, field: 'match' | 'permissions'): unknown {
|
|
433
|
+
if (typeof obj !== 'object' || obj === null) return undefined
|
|
434
|
+
const roles = (obj as Record<string, unknown>).roles
|
|
435
|
+
if (typeof roles !== 'object' || roles === null) return undefined
|
|
436
|
+
const projection: Record<string, unknown> = {}
|
|
437
|
+
for (const [roleName, roleVal] of Object.entries(roles as Record<string, unknown>)) {
|
|
438
|
+
if (typeof roleVal !== 'object' || roleVal === null) continue
|
|
439
|
+
const val = (roleVal as Record<string, unknown>)[field]
|
|
440
|
+
if (val !== undefined) projection[roleName] = val
|
|
441
|
+
}
|
|
442
|
+
return projection
|
|
443
|
+
}
|
|
444
|
+
|
|
340
445
|
// Plugin configs live at the top level of typeclaw.json keyed by plugin name
|
|
341
446
|
// (e.g. "standup-log": { ... }). They are preserved by configSchema.catchall(z.unknown())
|
|
342
447
|
// because the schema does not predeclare these keys. This helper returns the
|
|
@@ -347,14 +452,17 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
|
|
|
347
452
|
const known = new Set([
|
|
348
453
|
'$schema',
|
|
349
454
|
'port',
|
|
350
|
-
'
|
|
455
|
+
'models',
|
|
351
456
|
'mounts',
|
|
352
457
|
'plugins',
|
|
458
|
+
'alias',
|
|
353
459
|
'channels',
|
|
354
460
|
'portForward',
|
|
355
461
|
'network',
|
|
356
|
-
'
|
|
357
|
-
'
|
|
462
|
+
'docker',
|
|
463
|
+
'git',
|
|
464
|
+
'roles',
|
|
465
|
+
'permissions',
|
|
358
466
|
])
|
|
359
467
|
const result: Record<string, unknown> = {}
|
|
360
468
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
@@ -376,7 +484,11 @@ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
|
|
|
376
484
|
} catch {
|
|
377
485
|
return {}
|
|
378
486
|
}
|
|
379
|
-
|
|
487
|
+
const migrated = migrateLegacyConfigShape(json)
|
|
488
|
+
if (migrated.changed) {
|
|
489
|
+
persistMigratedConfig(cwd, migrated.json, migrated.applied)
|
|
490
|
+
}
|
|
491
|
+
return extractPluginConfigs(migrated.json)
|
|
380
492
|
}
|
|
381
493
|
|
|
382
494
|
export function loadConfigSync(cwd: string): Config {
|
|
@@ -395,13 +507,368 @@ export function loadConfigSync(cwd: string): Config {
|
|
|
395
507
|
throw new Error(`${CONFIG_FILE} is not valid JSON: ${detail}`)
|
|
396
508
|
}
|
|
397
509
|
|
|
398
|
-
const
|
|
510
|
+
const migrated = migrateLegacyConfigShape(json)
|
|
511
|
+
if (migrated.changed) {
|
|
512
|
+
persistMigratedConfig(cwd, migrated.json, migrated.applied)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const result = configSchema.safeParse(migrated.json)
|
|
399
516
|
if (!result.success) {
|
|
400
517
|
throw new Error(`${CONFIG_FILE} is invalid: ${formatZodError(result.error)}`)
|
|
401
518
|
}
|
|
402
519
|
return result.data
|
|
403
520
|
}
|
|
404
521
|
|
|
522
|
+
// One-shot rename of legacy top-level `dockerfile` / `gitignore` keys into the
|
|
523
|
+
// nested `docker.file` / `git.ignore` shape introduced for namespace
|
|
524
|
+
// extensibility (`docker.compose`, `git.attributes`, etc. land here later
|
|
525
|
+
// without a second migration). Called from every entry point that reads
|
|
526
|
+
// `typeclaw.json` so the rest of the pipeline only ever sees the new shape.
|
|
527
|
+
//
|
|
528
|
+
// Precedence when both legacy and new keys coexist: the new shape wins and
|
|
529
|
+
// the legacy key is dropped silently. Two ways this happens in practice:
|
|
530
|
+
// 1. User hand-edited the new shape after auto-migration but forgot to
|
|
531
|
+
// delete the legacy key.
|
|
532
|
+
// 2. Two `typeclaw start` invocations raced on a stale checkout.
|
|
533
|
+
// Either way, the new shape is the source of truth — losing the legacy
|
|
534
|
+
// duplicate is the right call because it would otherwise be shadowed at
|
|
535
|
+
// parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
|
|
536
|
+
//
|
|
537
|
+
// The returned `applied` array names each migration step that fired, so
|
|
538
|
+
// callers in `typeclaw start` can build a meaningful git commit message
|
|
539
|
+
// instead of a generic "migrate legacy shape" subject. `changed` is the
|
|
540
|
+
// boolean equivalent of `applied.length > 0` and is preserved for back-compat
|
|
541
|
+
// with the many call sites that only care whether ANY rewrite happened.
|
|
542
|
+
export type MigrationStep =
|
|
543
|
+
| { kind: 'dockerfile-to-docker-file' }
|
|
544
|
+
| { kind: 'gitignore-to-git-ignore' }
|
|
545
|
+
| { kind: 'channels-allow-to-roles-member-match'; rules: string[]; dropped: string[] }
|
|
546
|
+
| { kind: 'strip-permissions-gate-channel-respond' }
|
|
547
|
+
| { kind: 'model-to-models'; ref: string }
|
|
548
|
+
| { kind: 'drop-stale-model'; ref: string }
|
|
549
|
+
|
|
550
|
+
export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
|
|
551
|
+
|
|
552
|
+
export function migrateLegacyConfigShape(json: unknown): MigrationResult {
|
|
553
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
554
|
+
return { json, changed: false, applied: [] }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const obj = json as Record<string, unknown>
|
|
558
|
+
const hasLegacyDockerfile = 'dockerfile' in obj
|
|
559
|
+
const hasLegacyGitignore = 'gitignore' in obj
|
|
560
|
+
const channelsAllowMigration = collectChannelsAllowMigration(obj)
|
|
561
|
+
const hasLegacyGateChannelRespond = isPlainObject(obj.permissions) && 'gateChannelRespond' in obj.permissions
|
|
562
|
+
// The pre-multi-model schema had a top-level `model: KnownModelRef` and no
|
|
563
|
+
// `models` key. Detecting the legacy shape requires both: `model` present
|
|
564
|
+
// AND `models` absent. If both coexist (user hand-edited after auto-migrate
|
|
565
|
+
// but didn't delete the legacy key), `models` wins and `model` is dropped
|
|
566
|
+
// silently — same precedence rule as the dockerfile/gitignore migrations.
|
|
567
|
+
const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
|
|
568
|
+
const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
|
|
569
|
+
if (
|
|
570
|
+
!hasLegacyDockerfile &&
|
|
571
|
+
!hasLegacyGitignore &&
|
|
572
|
+
!channelsAllowMigration.found &&
|
|
573
|
+
!hasLegacyGateChannelRespond &&
|
|
574
|
+
!hasLegacyModel &&
|
|
575
|
+
!hasStaleModelAlongsideModels
|
|
576
|
+
) {
|
|
577
|
+
return { json, changed: false, applied: [] }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const applied: MigrationStep[] = []
|
|
581
|
+
const next: Record<string, unknown> = { ...obj }
|
|
582
|
+
if (hasLegacyDockerfile) {
|
|
583
|
+
const legacy = next.dockerfile
|
|
584
|
+
delete next.dockerfile
|
|
585
|
+
if (!('docker' in next)) {
|
|
586
|
+
next.docker = { file: legacy }
|
|
587
|
+
} else if (isPlainObject(next.docker) && !('file' in next.docker)) {
|
|
588
|
+
next.docker = { ...next.docker, file: legacy }
|
|
589
|
+
}
|
|
590
|
+
applied.push({ kind: 'dockerfile-to-docker-file' })
|
|
591
|
+
}
|
|
592
|
+
if (hasLegacyGitignore) {
|
|
593
|
+
const legacy = next.gitignore
|
|
594
|
+
delete next.gitignore
|
|
595
|
+
if (!('git' in next)) {
|
|
596
|
+
next.git = { ignore: legacy }
|
|
597
|
+
} else if (isPlainObject(next.git) && !('ignore' in next.git)) {
|
|
598
|
+
next.git = { ...next.git, ignore: legacy }
|
|
599
|
+
}
|
|
600
|
+
applied.push({ kind: 'gitignore-to-git-ignore' })
|
|
601
|
+
}
|
|
602
|
+
if (channelsAllowMigration.found) {
|
|
603
|
+
applyChannelsAllowMigration(next, channelsAllowMigration)
|
|
604
|
+
applied.push({
|
|
605
|
+
kind: 'channels-allow-to-roles-member-match',
|
|
606
|
+
rules: channelsAllowMigration.rules,
|
|
607
|
+
dropped: channelsAllowMigration.warnings,
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
if (hasLegacyGateChannelRespond) {
|
|
611
|
+
const perms = { ...(next.permissions as Record<string, unknown>) }
|
|
612
|
+
delete perms.gateChannelRespond
|
|
613
|
+
if (Object.keys(perms).length === 0) {
|
|
614
|
+
delete next.permissions
|
|
615
|
+
} else {
|
|
616
|
+
next.permissions = perms
|
|
617
|
+
}
|
|
618
|
+
applied.push({ kind: 'strip-permissions-gate-channel-respond' })
|
|
619
|
+
}
|
|
620
|
+
if (hasLegacyModel) {
|
|
621
|
+
const ref = next.model as string
|
|
622
|
+
delete next.model
|
|
623
|
+
next.models = { default: ref }
|
|
624
|
+
applied.push({ kind: 'model-to-models', ref })
|
|
625
|
+
} else if (hasStaleModelAlongsideModels) {
|
|
626
|
+
// `models` wins (per the same precedence rule as dockerfile/gitignore), but
|
|
627
|
+
// the drop is still a tracked migration step so the disk rewrite gets a
|
|
628
|
+
// commit instead of silently dirtying the worktree. Without this, the
|
|
629
|
+
// file would be rewritten by persistMigratedConfig and no commit would
|
|
630
|
+
// fire (buildConfigMigrationCommitMessage returns null for empty applied
|
|
631
|
+
// lists), contradicting the invariant in persistMigratedConfig's comment.
|
|
632
|
+
const ref = typeof next.model === 'string' ? next.model : ''
|
|
633
|
+
delete next.model
|
|
634
|
+
applied.push({ kind: 'drop-stale-model', ref })
|
|
635
|
+
}
|
|
636
|
+
return { json: next, changed: true, applied }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Builds a meaningful one-line git commit subject for a typeclaw.json
|
|
640
|
+
// migration. Single-step migrations get a specific subject; multi-step ones
|
|
641
|
+
// fall back to a stable summary subject with the count. The body (after the
|
|
642
|
+
// blank line) enumerates each step so `git log -p typeclaw.json` is an
|
|
643
|
+
// auditable trail of what legacy shapes the agent has graduated from.
|
|
644
|
+
//
|
|
645
|
+
// Returns null when no steps were applied — callers should not commit in
|
|
646
|
+
// that case. Keeping the null branch here (vs an empty string) makes the
|
|
647
|
+
// "nothing happened" case impossible to misuse at the call site.
|
|
648
|
+
export function buildConfigMigrationCommitMessage(applied: readonly MigrationStep[]): string | null {
|
|
649
|
+
const first = applied[0]
|
|
650
|
+
if (first === undefined) return null
|
|
651
|
+
|
|
652
|
+
const subject =
|
|
653
|
+
applied.length === 1
|
|
654
|
+
? `typeclaw.json: ${shortStepLabel(first)}`
|
|
655
|
+
: `typeclaw.json: migrate legacy shape (${applied.length} steps)`
|
|
656
|
+
|
|
657
|
+
const bodyLines: string[] = applied.map((step) => `- ${describeStep(step)}`)
|
|
658
|
+
|
|
659
|
+
// Surface dropped rules in the commit body so a user inspecting `git log -p`
|
|
660
|
+
// sees exactly which legacy entries had to be hand-re-added (the lossy
|
|
661
|
+
// `channel:<id>` case). Without this, the silent-drop is invisible after
|
|
662
|
+
// the fact.
|
|
663
|
+
for (const step of applied) {
|
|
664
|
+
if (step.kind === 'channels-allow-to-roles-member-match' && step.dropped.length > 0) {
|
|
665
|
+
for (const warning of step.dropped) {
|
|
666
|
+
bodyLines.push(` warning: ${warning}`)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return `${subject}\n\n${bodyLines.join('\n')}\n`
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function shortStepLabel(step: MigrationStep): string {
|
|
675
|
+
switch (step.kind) {
|
|
676
|
+
case 'dockerfile-to-docker-file':
|
|
677
|
+
return 'lift dockerfile → docker.file'
|
|
678
|
+
case 'gitignore-to-git-ignore':
|
|
679
|
+
return 'lift gitignore → git.ignore'
|
|
680
|
+
case 'channels-allow-to-roles-member-match':
|
|
681
|
+
return 'lift channels.<adapter>.allow[] → roles.member.match[]'
|
|
682
|
+
case 'strip-permissions-gate-channel-respond':
|
|
683
|
+
return 'drop permissions.gateChannelRespond'
|
|
684
|
+
case 'model-to-models':
|
|
685
|
+
return 'lift model → models.default'
|
|
686
|
+
case 'drop-stale-model':
|
|
687
|
+
return 'drop stale legacy model alongside models'
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function describeStep(step: MigrationStep): string {
|
|
692
|
+
switch (step.kind) {
|
|
693
|
+
case 'dockerfile-to-docker-file':
|
|
694
|
+
return 'lift top-level dockerfile into docker.file'
|
|
695
|
+
case 'gitignore-to-git-ignore':
|
|
696
|
+
return 'lift top-level gitignore into git.ignore'
|
|
697
|
+
case 'channels-allow-to-roles-member-match': {
|
|
698
|
+
if (step.rules.length === 0) {
|
|
699
|
+
return 'strip channels.<adapter>.allow[] (no translatable rules)'
|
|
700
|
+
}
|
|
701
|
+
return `lift channels.<adapter>.allow[] → roles.member.match[]: ${step.rules.join(', ')}`
|
|
702
|
+
}
|
|
703
|
+
case 'strip-permissions-gate-channel-respond':
|
|
704
|
+
return 'drop permissions.gateChannelRespond (removed key)'
|
|
705
|
+
case 'model-to-models':
|
|
706
|
+
return `lift top-level model into models.default: ${step.ref}`
|
|
707
|
+
case 'drop-stale-model':
|
|
708
|
+
return step.ref !== ''
|
|
709
|
+
? `drop stale top-level model (${step.ref}) — models block takes precedence`
|
|
710
|
+
: 'drop stale top-level model — models block takes precedence'
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Channels.<adapter>.allow[] → roles.member.match[] migration.
|
|
715
|
+
//
|
|
716
|
+
// Phase 3 removes the per-adapter allow-list and unifies wake-up gating
|
|
717
|
+
// through `roles.member.match[]` + the `channel.respond` permission. This
|
|
718
|
+
// helper translates legacy `allow` entries into canonical match-rule DSL
|
|
719
|
+
// strings and appends them (deduplicated, preserving declaration order)
|
|
720
|
+
// to `roles.member.match[]`. The `allow` field is then stripped from each
|
|
721
|
+
// adapter block; the block survives — only the field is gone.
|
|
722
|
+
//
|
|
723
|
+
// `channel:<id>` rules cannot round-trip (the DSL forbids
|
|
724
|
+
// wildcard-workspace + specific-chat) and are dropped with a warning. All
|
|
725
|
+
// other shapes translate losslessly per the table in match-rule.ts.
|
|
726
|
+
type ChannelsAllowMigration = {
|
|
727
|
+
found: boolean
|
|
728
|
+
rules: string[]
|
|
729
|
+
warnings: string[]
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function collectChannelsAllowMigration(obj: Record<string, unknown>): ChannelsAllowMigration {
|
|
733
|
+
const out: ChannelsAllowMigration = { found: false, rules: [], warnings: [] }
|
|
734
|
+
const channels = obj.channels
|
|
735
|
+
if (!isPlainObject(channels)) return out
|
|
736
|
+
for (const [adapter, value] of Object.entries(channels)) {
|
|
737
|
+
if (!isPlainObject(value)) continue
|
|
738
|
+
if (!('allow' in value)) continue
|
|
739
|
+
out.found = true
|
|
740
|
+
const allow = value.allow
|
|
741
|
+
if (!Array.isArray(allow)) continue
|
|
742
|
+
for (const entry of allow) {
|
|
743
|
+
if (typeof entry !== 'string') continue
|
|
744
|
+
const translated = translateLegacyAllowRule(entry)
|
|
745
|
+
if (translated.kind === 'rule') {
|
|
746
|
+
out.rules.push(translated.value)
|
|
747
|
+
} else {
|
|
748
|
+
out.warnings.push(`channels.${adapter}.allow[]: dropped '${entry}' (${translated.reason})`)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return out
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function applyChannelsAllowMigration(next: Record<string, unknown>, migration: ChannelsAllowMigration): void {
|
|
756
|
+
const channels = next.channels
|
|
757
|
+
if (isPlainObject(channels)) {
|
|
758
|
+
const updated: Record<string, unknown> = {}
|
|
759
|
+
for (const [adapter, value] of Object.entries(channels)) {
|
|
760
|
+
if (isPlainObject(value) && 'allow' in value) {
|
|
761
|
+
const { allow: _allow, ...rest } = value
|
|
762
|
+
updated[adapter] = rest
|
|
763
|
+
} else {
|
|
764
|
+
updated[adapter] = value
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
next.channels = updated
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (migration.rules.length === 0) {
|
|
771
|
+
for (const warning of migration.warnings) {
|
|
772
|
+
console.warn(`[config] ${warning}`)
|
|
773
|
+
}
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const roles = isPlainObject(next.roles) ? { ...next.roles } : {}
|
|
778
|
+
const member = isPlainObject(roles.member) ? { ...roles.member } : {}
|
|
779
|
+
const existingMatch = Array.isArray(member.match)
|
|
780
|
+
? (member.match as unknown[]).filter((m) => typeof m === 'string')
|
|
781
|
+
: []
|
|
782
|
+
const seen = new Set<string>(existingMatch as string[])
|
|
783
|
+
const merged = [...(existingMatch as string[])]
|
|
784
|
+
for (const rule of migration.rules) {
|
|
785
|
+
if (!seen.has(rule)) {
|
|
786
|
+
seen.add(rule)
|
|
787
|
+
merged.push(rule)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
member.match = merged
|
|
791
|
+
roles.member = member
|
|
792
|
+
next.roles = roles
|
|
793
|
+
|
|
794
|
+
console.warn(`[config] migrated channels.<adapter>.allow[] -> roles.member.match[]: ${migration.rules.join(', ')}`)
|
|
795
|
+
for (const warning of migration.warnings) {
|
|
796
|
+
console.warn(`[config] ${warning}`)
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
type TranslatedRule = { kind: 'rule'; value: string } | { kind: 'drop'; reason: string }
|
|
801
|
+
|
|
802
|
+
function translateLegacyAllowRule(rule: string): TranslatedRule {
|
|
803
|
+
// Already canonical / cross-platform.
|
|
804
|
+
if (rule === '*') return { kind: 'rule', value: '*' }
|
|
805
|
+
if (rule.startsWith('kakao:')) return { kind: 'rule', value: rule }
|
|
806
|
+
|
|
807
|
+
// Discord: guild → discord, dm → discord:dm.
|
|
808
|
+
if (rule === 'guild:*') return { kind: 'rule', value: 'discord:*' }
|
|
809
|
+
if (rule.startsWith('guild:')) return { kind: 'rule', value: `discord:${rule.slice('guild:'.length)}` }
|
|
810
|
+
if (rule === 'dm:*') return { kind: 'rule', value: 'discord:dm/*' }
|
|
811
|
+
if (rule.startsWith('dm:')) return { kind: 'rule', value: `discord:dm/${rule.slice('dm:'.length)}` }
|
|
812
|
+
|
|
813
|
+
// Slack: team → slack, im → slack:dm.
|
|
814
|
+
if (rule === 'team:*') return { kind: 'rule', value: 'slack:*' }
|
|
815
|
+
if (rule.startsWith('team:')) return { kind: 'rule', value: `slack:${rule.slice('team:'.length)}` }
|
|
816
|
+
if (rule === 'im:*') return { kind: 'rule', value: 'slack:dm/*' }
|
|
817
|
+
if (rule.startsWith('im:')) return { kind: 'rule', value: `slack:dm/${rule.slice('im:'.length)}` }
|
|
818
|
+
|
|
819
|
+
// Telegram: tg → telegram.
|
|
820
|
+
if (rule === 'tg:*') return { kind: 'rule', value: 'telegram:*' }
|
|
821
|
+
if (rule.startsWith('tg:')) return { kind: 'rule', value: `telegram:${rule.slice('tg:'.length)}` }
|
|
822
|
+
|
|
823
|
+
// `channel:<id>` had no workspace; canonical DSL rejects wildcard
|
|
824
|
+
// workspace + specific chat. Drop with a warning so the operator knows
|
|
825
|
+
// to re-add the rule explicitly with a workspace coordinate.
|
|
826
|
+
if (rule.startsWith('channel:')) {
|
|
827
|
+
return {
|
|
828
|
+
kind: 'drop',
|
|
829
|
+
reason:
|
|
830
|
+
'channel:<id> rules require an explicit workspace under the new DSL; re-add as discord:<guild>/<id> or slack:<team>/<id>',
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return { kind: 'drop', reason: `unrecognized legacy allow shape '${rule}'` }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
838
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function persistMigratedConfig(cwd: string, json: unknown, applied: readonly MigrationStep[]): void {
|
|
842
|
+
try {
|
|
843
|
+
writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(json, null, 2)}\n`)
|
|
844
|
+
} catch {
|
|
845
|
+
// Best-effort write-back: the migration is also applied in-memory on every
|
|
846
|
+
// load, so a read-only filesystem (e.g. snapshotted CI checkout) just
|
|
847
|
+
// means the rewrite retries next start. Surfacing the error would brick
|
|
848
|
+
// load paths the user didn't ask to mutate. Bail before the commit step
|
|
849
|
+
// too — without the write there's nothing to commit.
|
|
850
|
+
return
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Pair the disk rewrite with a git commit so the agent folder is never
|
|
854
|
+
// silently dirty after a legacy-shape migration. typeclaw.json is in
|
|
855
|
+
// git's "tracked" category (unlike Dockerfile, which is regenerated on
|
|
856
|
+
// every start and intentionally gitignored), so an uncommitted rewrite
|
|
857
|
+
// gets mixed into unrelated commits the moment any other tool touches
|
|
858
|
+
// the repo. commitSystemFileSync no-ops on non-git folders, missing
|
|
859
|
+
// Bun, and clean files, so canonical-shape reads pay zero cost.
|
|
860
|
+
//
|
|
861
|
+
// Called from every entry point that reads typeclaw.json (host CLI,
|
|
862
|
+
// hostd daemon, container runtime) so the commit follows the rewrite
|
|
863
|
+
// wherever it happens — not only from `typeclaw start`. The earlier
|
|
864
|
+
// design that committed only in start() missed the long-running hostd
|
|
865
|
+
// daemon, doctor, tui, reload, and compose paths.
|
|
866
|
+
const message = buildConfigMigrationCommitMessage(applied)
|
|
867
|
+
if (message !== null) {
|
|
868
|
+
commitSystemFileSync(cwd, CONFIG_FILE, message)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
405
872
|
export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
|
|
406
873
|
|
|
407
874
|
// Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
|
|
@@ -430,7 +897,12 @@ export function validateConfig(cwd: string): ValidateConfigResult {
|
|
|
430
897
|
return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
|
|
431
898
|
}
|
|
432
899
|
|
|
433
|
-
const
|
|
900
|
+
const migrated = migrateLegacyConfigShape(json)
|
|
901
|
+
if (migrated.changed) {
|
|
902
|
+
persistMigratedConfig(cwd, migrated.json, migrated.applied)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const result = configSchema.safeParse(migrated.json)
|
|
434
906
|
if (!result.success) {
|
|
435
907
|
return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
|
|
436
908
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
export {
|
|
2
|
+
buildConfigMigrationCommitMessage,
|
|
2
3
|
config,
|
|
3
4
|
configSchema,
|
|
5
|
+
dockerSchema,
|
|
6
|
+
dockerfileSchema,
|
|
4
7
|
expandMountPath,
|
|
5
8
|
extractPluginConfigs,
|
|
6
9
|
getConfig,
|
|
10
|
+
gitSchema,
|
|
11
|
+
gitignoreSchema,
|
|
7
12
|
loadConfigSync,
|
|
8
13
|
loadPluginConfigsSync,
|
|
14
|
+
migrateLegacyConfigShape,
|
|
15
|
+
modelsSchema,
|
|
9
16
|
mountSchema,
|
|
10
|
-
dockerfileSchema,
|
|
11
17
|
portForwardSchema,
|
|
12
18
|
reloadConfig,
|
|
13
19
|
resolveModel,
|
|
20
|
+
resolveProfile,
|
|
14
21
|
validateConfig,
|
|
15
22
|
validateMount,
|
|
16
23
|
type Config,
|
|
17
24
|
type ConfigChange,
|
|
18
25
|
type ConfigReloadDiff,
|
|
26
|
+
type DockerConfig,
|
|
19
27
|
type DockerfileConfig,
|
|
28
|
+
type GitConfig,
|
|
29
|
+
type GitignoreConfig,
|
|
30
|
+
type MigrationResult,
|
|
31
|
+
type MigrationStep,
|
|
32
|
+
type Models,
|
|
20
33
|
type Mount,
|
|
21
34
|
type PortForward,
|
|
35
|
+
type ResolvedProfile,
|
|
22
36
|
type ValidateConfigResult,
|
|
23
37
|
} from './config'
|
|
24
38
|
export { type KnownModelRef, type KnownProviderId } from './providers'
|