typeclaw 0.1.5 → 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 +14 -12
- 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 +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- 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 +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- 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 +385 -12
- package/src/config/index.ts +7 -0
- 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 +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- 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 +50 -33
- 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 +32 -6
- package/src/init/index.ts +183 -62
- 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/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 +55 -6
- 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 +68 -0
- package/src/server/index.ts +122 -11
- 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 +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- 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 +57 -45
package/src/config/config.ts
CHANGED
|
@@ -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,
|
|
@@ -203,11 +205,41 @@ export const networkSchema = z
|
|
|
203
205
|
|
|
204
206
|
export type NetworkConfig = z.infer<typeof networkSchema>
|
|
205
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
|
+
|
|
206
235
|
export const configSchema = z
|
|
207
236
|
.object({
|
|
208
237
|
$schema: z.string().optional(),
|
|
209
238
|
port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
|
|
210
|
-
|
|
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>,
|
|
211
243
|
// Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
|
|
212
244
|
// host paths exposed) without failing the whole config load. `typeclaw
|
|
213
245
|
// init` omits this field so users don't see noise for the empty case.
|
|
@@ -225,6 +257,7 @@ export const configSchema = z
|
|
|
225
257
|
network: networkSchema,
|
|
226
258
|
docker: dockerSchema,
|
|
227
259
|
git: gitSchema,
|
|
260
|
+
roles: rolesConfigSchema.optional(),
|
|
228
261
|
})
|
|
229
262
|
.catchall(z.unknown())
|
|
230
263
|
|
|
@@ -238,6 +271,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
|
|
|
238
271
|
return KNOWN_PROVIDERS[providerId].models[modelId as never]
|
|
239
272
|
}
|
|
240
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
|
+
|
|
241
296
|
// Resolves a mount's `path` field to an absolute host path, mirroring shell
|
|
242
297
|
// expansion rules: `~`/`~/...` → home dir, relative → resolved against `cwd`,
|
|
243
298
|
// absolute → unchanged. Single source of truth so validation and Docker arg
|
|
@@ -306,7 +361,7 @@ export type FieldEffect = 'applied' | 'restart-required' | 'ignored'
|
|
|
306
361
|
|
|
307
362
|
export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
308
363
|
$schema: 'ignored',
|
|
309
|
-
|
|
364
|
+
models: 'applied',
|
|
310
365
|
port: 'restart-required',
|
|
311
366
|
mounts: 'restart-required',
|
|
312
367
|
plugins: 'restart-required',
|
|
@@ -316,6 +371,16 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
|
316
371
|
network: 'restart-required',
|
|
317
372
|
'docker.file': 'restart-required',
|
|
318
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',
|
|
319
384
|
}
|
|
320
385
|
|
|
321
386
|
// Stable JSON for value comparison. Fields are small JSON-shaped objects, so
|
|
@@ -354,6 +419,8 @@ function diffConfig(before: Config, after: Config): ConfigReloadDiff {
|
|
|
354
419
|
}
|
|
355
420
|
|
|
356
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')
|
|
357
424
|
let cur: unknown = obj
|
|
358
425
|
for (const part of path.split('.')) {
|
|
359
426
|
if (cur === null || cur === undefined) return undefined
|
|
@@ -362,6 +429,19 @@ function readPath(obj: unknown, path: string): unknown {
|
|
|
362
429
|
return cur
|
|
363
430
|
}
|
|
364
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
|
+
|
|
365
445
|
// Plugin configs live at the top level of typeclaw.json keyed by plugin name
|
|
366
446
|
// (e.g. "standup-log": { ... }). They are preserved by configSchema.catchall(z.unknown())
|
|
367
447
|
// because the schema does not predeclare these keys. This helper returns the
|
|
@@ -372,14 +452,17 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
|
|
|
372
452
|
const known = new Set([
|
|
373
453
|
'$schema',
|
|
374
454
|
'port',
|
|
375
|
-
'
|
|
455
|
+
'models',
|
|
376
456
|
'mounts',
|
|
377
457
|
'plugins',
|
|
458
|
+
'alias',
|
|
378
459
|
'channels',
|
|
379
460
|
'portForward',
|
|
380
461
|
'network',
|
|
381
462
|
'docker',
|
|
382
463
|
'git',
|
|
464
|
+
'roles',
|
|
465
|
+
'permissions',
|
|
383
466
|
])
|
|
384
467
|
const result: Record<string, unknown> = {}
|
|
385
468
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
@@ -402,6 +485,9 @@ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
|
|
|
402
485
|
return {}
|
|
403
486
|
}
|
|
404
487
|
const migrated = migrateLegacyConfigShape(json)
|
|
488
|
+
if (migrated.changed) {
|
|
489
|
+
persistMigratedConfig(cwd, migrated.json, migrated.applied)
|
|
490
|
+
}
|
|
405
491
|
return extractPluginConfigs(migrated.json)
|
|
406
492
|
}
|
|
407
493
|
|
|
@@ -423,7 +509,7 @@ export function loadConfigSync(cwd: string): Config {
|
|
|
423
509
|
|
|
424
510
|
const migrated = migrateLegacyConfigShape(json)
|
|
425
511
|
if (migrated.changed) {
|
|
426
|
-
persistMigratedConfig(cwd, migrated.json)
|
|
512
|
+
persistMigratedConfig(cwd, migrated.json, migrated.applied)
|
|
427
513
|
}
|
|
428
514
|
|
|
429
515
|
const result = configSchema.safeParse(migrated.json)
|
|
@@ -447,18 +533,51 @@ export function loadConfigSync(cwd: string): Config {
|
|
|
447
533
|
// Either way, the new shape is the source of truth — losing the legacy
|
|
448
534
|
// duplicate is the right call because it would otherwise be shadowed at
|
|
449
535
|
// parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
|
|
450
|
-
|
|
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 {
|
|
451
553
|
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
452
|
-
return { json, changed: false }
|
|
554
|
+
return { json, changed: false, applied: [] }
|
|
453
555
|
}
|
|
454
556
|
|
|
455
557
|
const obj = json as Record<string, unknown>
|
|
456
558
|
const hasLegacyDockerfile = 'dockerfile' in obj
|
|
457
559
|
const hasLegacyGitignore = 'gitignore' in obj
|
|
458
|
-
|
|
459
|
-
|
|
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: [] }
|
|
460
578
|
}
|
|
461
579
|
|
|
580
|
+
const applied: MigrationStep[] = []
|
|
462
581
|
const next: Record<string, unknown> = { ...obj }
|
|
463
582
|
if (hasLegacyDockerfile) {
|
|
464
583
|
const legacy = next.dockerfile
|
|
@@ -468,6 +587,7 @@ export function migrateLegacyConfigShape(json: unknown): { json: unknown; change
|
|
|
468
587
|
} else if (isPlainObject(next.docker) && !('file' in next.docker)) {
|
|
469
588
|
next.docker = { ...next.docker, file: legacy }
|
|
470
589
|
}
|
|
590
|
+
applied.push({ kind: 'dockerfile-to-docker-file' })
|
|
471
591
|
}
|
|
472
592
|
if (hasLegacyGitignore) {
|
|
473
593
|
const legacy = next.gitignore
|
|
@@ -477,22 +597,275 @@ export function migrateLegacyConfigShape(json: unknown): { json: unknown; change
|
|
|
477
597
|
} else if (isPlainObject(next.git) && !('ignore' in next.git)) {
|
|
478
598
|
next.git = { ...next.git, ignore: legacy }
|
|
479
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'
|
|
480
688
|
}
|
|
481
|
-
|
|
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}'` }
|
|
482
835
|
}
|
|
483
836
|
|
|
484
837
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
485
838
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
486
839
|
}
|
|
487
840
|
|
|
488
|
-
function persistMigratedConfig(cwd: string, json: unknown): void {
|
|
841
|
+
function persistMigratedConfig(cwd: string, json: unknown, applied: readonly MigrationStep[]): void {
|
|
489
842
|
try {
|
|
490
843
|
writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(json, null, 2)}\n`)
|
|
491
844
|
} catch {
|
|
492
845
|
// Best-effort write-back: the migration is also applied in-memory on every
|
|
493
846
|
// load, so a read-only filesystem (e.g. snapshotted CI checkout) just
|
|
494
847
|
// means the rewrite retries next start. Surfacing the error would brick
|
|
495
|
-
// load paths the user didn't ask to mutate.
|
|
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)
|
|
496
869
|
}
|
|
497
870
|
}
|
|
498
871
|
|
|
@@ -526,7 +899,7 @@ export function validateConfig(cwd: string): ValidateConfigResult {
|
|
|
526
899
|
|
|
527
900
|
const migrated = migrateLegacyConfigShape(json)
|
|
528
901
|
if (migrated.changed) {
|
|
529
|
-
persistMigratedConfig(cwd, migrated.json)
|
|
902
|
+
persistMigratedConfig(cwd, migrated.json, migrated.applied)
|
|
530
903
|
}
|
|
531
904
|
|
|
532
905
|
const result = configSchema.safeParse(migrated.json)
|
package/src/config/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export {
|
|
2
|
+
buildConfigMigrationCommitMessage,
|
|
2
3
|
config,
|
|
3
4
|
configSchema,
|
|
4
5
|
dockerSchema,
|
|
@@ -11,10 +12,12 @@ export {
|
|
|
11
12
|
loadConfigSync,
|
|
12
13
|
loadPluginConfigsSync,
|
|
13
14
|
migrateLegacyConfigShape,
|
|
15
|
+
modelsSchema,
|
|
14
16
|
mountSchema,
|
|
15
17
|
portForwardSchema,
|
|
16
18
|
reloadConfig,
|
|
17
19
|
resolveModel,
|
|
20
|
+
resolveProfile,
|
|
18
21
|
validateConfig,
|
|
19
22
|
validateMount,
|
|
20
23
|
type Config,
|
|
@@ -24,8 +27,12 @@ export {
|
|
|
24
27
|
type DockerfileConfig,
|
|
25
28
|
type GitConfig,
|
|
26
29
|
type GitignoreConfig,
|
|
30
|
+
type MigrationResult,
|
|
31
|
+
type MigrationStep,
|
|
32
|
+
type Models,
|
|
27
33
|
type Mount,
|
|
28
34
|
type PortForward,
|
|
35
|
+
type ResolvedProfile,
|
|
29
36
|
type ValidateConfigResult,
|
|
30
37
|
} from './config'
|
|
31
38
|
export { type KnownModelRef, type KnownProviderId } from './providers'
|