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.
Files changed (134) hide show
  1. package/README.md +15 -13
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -14,6 +14,11 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
14
14
  // entries are surfaced regardless of upstream membership.
15
15
  'openai-codex': 'openai',
16
16
  fireworks: 'fireworks-ai',
17
+ zai: 'zai',
18
+ // zai-coding (GLM Coding Plan) is a billing surface, not a separate model
19
+ // catalog. models.dev tracks the underlying model metadata under `zai`,
20
+ // so we route lookups there. The curated entries still get surfaced.
21
+ 'zai-coding': 'zai',
17
22
  }
18
23
 
19
24
  export type ModelOption = {
@@ -25,6 +30,13 @@ export type ModelOption = {
25
30
  reasoning: boolean
26
31
  contextWindow: number | null
27
32
  curated: boolean
33
+ // True iff the model accepts image input. Sourced from the curated
34
+ // `Model.input` array (which is the source of truth — pi-ai consumes it
35
+ // directly) with a fallback to models.dev's `modalities.input` when the
36
+ // curated entry omits the field. The init wizard uses this to decide
37
+ // whether to prompt for a separate `vision` profile after the user picks
38
+ // a text-only `default` model.
39
+ supportsVision: boolean
28
40
  }
29
41
 
30
42
  type ModelsDevModel = {
@@ -115,7 +127,10 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
115
127
  const modelId = ref.slice(slash + 1)
116
128
  const provider = KNOWN_PROVIDERS[providerId]
117
129
  const curatedModel = (
118
- provider.models as Record<string, { name: string; contextWindow?: number; reasoning?: boolean }>
130
+ provider.models as Record<
131
+ string,
132
+ { name: string; contextWindow?: number; reasoning?: boolean; input?: ReadonlyArray<string> }
133
+ >
119
134
  )[modelId]
120
135
  return {
121
136
  ref,
@@ -126,5 +141,15 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
126
141
  reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
127
142
  contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
128
143
  curated: opts.curated,
144
+ supportsVision: resolveSupportsVision(curatedModel?.input, opts.upstream?.modalities?.input),
129
145
  }
130
146
  }
147
+
148
+ function resolveSupportsVision(
149
+ curatedInput: ReadonlyArray<string> | undefined,
150
+ upstreamInput: ReadonlyArray<string> | undefined,
151
+ ): boolean {
152
+ if (curatedInput !== undefined) return curatedInput.includes('image')
153
+ if (upstreamInput !== undefined) return upstreamInput.includes('image')
154
+ return false
155
+ }
@@ -0,0 +1,77 @@
1
+ import { confirm, isCancel, log, note, spinner } from '@clack/prompts'
2
+
3
+ import { c } from '@/cli/ui'
4
+ import { runClaimSession } from '@/role-claim'
5
+
6
+ import type { ChannelKind } from './index'
7
+
8
+ const CHANNEL_LABELS: Record<ChannelKind, string> = {
9
+ 'slack-bot': 'Slack',
10
+ 'discord-bot': 'Discord',
11
+ 'telegram-bot': 'Telegram',
12
+ kakaotalk: 'KakaoTalk',
13
+ }
14
+
15
+ const DEFAULT_TTL_MS = 10 * 60 * 1000
16
+
17
+ export type RunOwnerClaimOptions = {
18
+ url: string
19
+ configuredChannels: readonly ChannelKind[]
20
+ }
21
+
22
+ // Drives the post-hatching claim flow: ask the operator whether to pair now,
23
+ // run the claim handshake against the running container, print the result.
24
+ // Aborts (kind: 'cancel' or a clack cancel) drop straight back into the
25
+ // normal hatching path so the TUI still opens — the operator can run
26
+ // `typeclaw role claim` later.
27
+ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
28
+ if (configuredChannels.length === 0) return
29
+
30
+ const channelList = configuredChannels.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
31
+
32
+ const proceed = await confirm({
33
+ message: `Claim owner role on ${channelList} now?`,
34
+ initialValue: true,
35
+ })
36
+ if (isCancel(proceed) || proceed === false) {
37
+ log.info(`Skipping. Run ${c.bold('typeclaw role claim')} later when you're ready.`)
38
+ return
39
+ }
40
+
41
+ const s = spinner()
42
+ s.start('Generating your claim code...')
43
+
44
+ const result = await runClaimSession({
45
+ url,
46
+ role: 'owner',
47
+ ttlMs: DEFAULT_TTL_MS,
48
+ onStarted: (payload) => {
49
+ const expiresInMin = Math.max(1, Math.round((payload.expiresAt - Date.now()) / 60_000))
50
+ s.stop('Code ready.')
51
+ note(
52
+ [
53
+ `Open ${channelList} and DM your bot with this code:`,
54
+ '',
55
+ ` ${c.bold(payload.code)}`,
56
+ '',
57
+ `(expires in ~${expiresInMin}m)`,
58
+ ].join('\n'),
59
+ 'Claim your owner role',
60
+ )
61
+ s.start('Waiting for your DM...')
62
+ },
63
+ })
64
+
65
+ if (result.kind === 'completed') {
66
+ s.stop(c.green(`Paired as owner.`))
67
+ log.info(`Match rule added to typeclaw.json#roles.owner.match: ${c.bold(result.payload.matchRule)}`)
68
+ return
69
+ }
70
+ if (result.kind === 'error') {
71
+ s.stop(c.red(`Claim failed: ${result.payload.reason}`))
72
+ log.info(`You can retry with ${c.bold('typeclaw role claim')} anytime.`)
73
+ return
74
+ }
75
+ s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
76
+ log.info(`Run ${c.bold('typeclaw role claim')} when you're ready.`)
77
+ }
@@ -0,0 +1,70 @@
1
+ import type { MatchRule } from './match-rule'
2
+
3
+ export type BuiltinRoleName = 'owner' | 'trusted' | 'member' | 'guest'
4
+
5
+ export const BUILTIN_ROLE_NAMES: readonly BuiltinRoleName[] = ['owner', 'trusted', 'member', 'guest']
6
+
7
+ // Core-owned permission strings; not contributed by plugins. The security
8
+ // plugin's `security.bypass.*` strings are NOT listed here — they are
9
+ // collected from plugin contributions and merged into `owner`'s permission
10
+ // set at boot via expandOwnerWildcard.
11
+ export const CORE_PERMISSIONS = {
12
+ channelRespond: 'channel.respond',
13
+ cronSchedule: 'cron.schedule',
14
+ cronModify: 'cron.modify',
15
+ } as const
16
+
17
+ // Sentinel that `expandOwnerWildcard` swaps for the concrete union of
18
+ // plugin-registered `security.bypass.*` strings. Users cannot write `*` in
19
+ // their own `permissions[]`; the sentinel exists only inside the built-in
20
+ // `owner` spec.
21
+ export const OWNER_SECURITY_WILDCARD = '__BUILTIN_OWNER_SECURITY_WILDCARD__'
22
+
23
+ export type BuiltinRoleSpec = {
24
+ readonly match: readonly MatchRule[]
25
+ readonly permissions: readonly string[]
26
+ }
27
+
28
+ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
29
+ owner: {
30
+ match: [{ kind: 'tui' }],
31
+ permissions: [
32
+ CORE_PERMISSIONS.channelRespond,
33
+ CORE_PERMISSIONS.cronSchedule,
34
+ CORE_PERMISSIONS.cronModify,
35
+ OWNER_SECURITY_WILDCARD,
36
+ ],
37
+ },
38
+ trusted: {
39
+ match: [],
40
+ permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.secretExfilBash'],
41
+ },
42
+ member: {
43
+ match: [],
44
+ permissions: [CORE_PERMISSIONS.channelRespond],
45
+ },
46
+ guest: {
47
+ match: [],
48
+ permissions: [],
49
+ },
50
+ }
51
+
52
+ export function expandOwnerWildcard(
53
+ ownerPermissions: readonly string[],
54
+ pluginContributed: readonly string[],
55
+ ): readonly string[] {
56
+ const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.'))
57
+ const out: string[] = []
58
+ for (const p of ownerPermissions) {
59
+ if (p === OWNER_SECURITY_WILDCARD) {
60
+ for (const b of bypass) if (!out.includes(b)) out.push(b)
61
+ continue
62
+ }
63
+ if (!out.includes(p)) out.push(p)
64
+ }
65
+ return out
66
+ }
67
+
68
+ export function isBuiltinRoleName(name: string): name is BuiltinRoleName {
69
+ return (BUILTIN_ROLE_NAMES as readonly string[]).includes(name)
70
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parseMatchRule } from './match-rule'
5
+
6
+ // Appends `rule` to `typeclaw.json#roles.<name>.match`, creating the role
7
+ // block when missing. Idempotent: re-granting the same rule is a no-op.
8
+ // Atomic: writes via temp+rename so a crashed write never leaves a partial
9
+ // JSON file that would brick the next `typeclaw start`.
10
+ //
11
+ // Used by the role-claim flow (after a successful handshake) and by any
12
+ // future operator-direct grant command. The match rule is validated
13
+ // against the same parser that runs at config load, so a malformed rule
14
+ // fails here instead of bricking the next start.
15
+
16
+ const CONFIG_FILE = 'typeclaw.json'
17
+
18
+ export type GrantResult = { ok: true; added: boolean } | { ok: false; reason: string }
19
+
20
+ export type GrantOptions = {
21
+ cwd: string
22
+ roleName: string
23
+ matchRule: string
24
+ }
25
+
26
+ export function grantRole(opts: GrantOptions): GrantResult {
27
+ const validation = parseMatchRule(opts.matchRule)
28
+ if (!validation.ok) {
29
+ return { ok: false, reason: `invalid match rule '${opts.matchRule}': ${validation.error}` }
30
+ }
31
+
32
+ const path = join(opts.cwd, CONFIG_FILE)
33
+ if (!existsSync(path)) {
34
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${opts.cwd}` }
35
+ }
36
+
37
+ let raw: string
38
+ try {
39
+ raw = readFileSync(path, 'utf8')
40
+ } catch (error) {
41
+ return { ok: false, reason: `failed to read ${CONFIG_FILE}: ${describeError(error)}` }
42
+ }
43
+
44
+ let json: unknown
45
+ try {
46
+ json = JSON.parse(raw)
47
+ } catch (error) {
48
+ return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${describeError(error)}` }
49
+ }
50
+
51
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
52
+ return { ok: false, reason: `${CONFIG_FILE} must be a JSON object` }
53
+ }
54
+
55
+ const obj = json as Record<string, unknown>
56
+ const roles = isPlainObject(obj.roles) ? { ...obj.roles } : {}
57
+ const role = isPlainObject(roles[opts.roleName]) ? { ...(roles[opts.roleName] as Record<string, unknown>) } : {}
58
+ const existingMatch = Array.isArray(role.match) ? [...(role.match as unknown[])] : []
59
+
60
+ // Dedup by exact string equality — match rules canonicalize during load,
61
+ // and a literal duplicate in the file is a noise source the schema doesn't
62
+ // currently dedupe for us.
63
+ if (existingMatch.includes(opts.matchRule)) {
64
+ return { ok: true, added: false }
65
+ }
66
+
67
+ role.match = [...existingMatch, opts.matchRule]
68
+ roles[opts.roleName] = role
69
+ obj.roles = roles
70
+
71
+ try {
72
+ writeAtomic(path, `${JSON.stringify(obj, null, 2)}\n`)
73
+ } catch (error) {
74
+ return { ok: false, reason: `failed to write ${CONFIG_FILE}: ${describeError(error)}` }
75
+ }
76
+
77
+ return { ok: true, added: true }
78
+ }
79
+
80
+ function writeAtomic(path: string, content: string): void {
81
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`
82
+ writeFileSync(tmp, content)
83
+ try {
84
+ renameSync(tmp, path)
85
+ } catch (error) {
86
+ try {
87
+ unlinkSync(tmp)
88
+ } catch {}
89
+ throw error
90
+ }
91
+ }
92
+
93
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
94
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
95
+ }
96
+
97
+ function describeError(error: unknown): string {
98
+ return error instanceof Error ? error.message : String(error)
99
+ }
@@ -0,0 +1,29 @@
1
+ export {
2
+ BUILTIN_ROLE_NAMES,
3
+ BUILTIN_ROLES,
4
+ CORE_PERMISSIONS,
5
+ OWNER_SECURITY_WILDCARD,
6
+ expandOwnerWildcard,
7
+ isBuiltinRoleName,
8
+ type BuiltinRoleName,
9
+ type BuiltinRoleSpec,
10
+ } from './builtins'
11
+ export {
12
+ MATCH_RULE_REGEX_SOURCE,
13
+ PLATFORMS,
14
+ parseMatchRule,
15
+ type MatchRule,
16
+ type ParseMatchRuleResult,
17
+ type Platform,
18
+ } from './match-rule'
19
+ export {
20
+ createPermissionService,
21
+ findUnknownPermissions,
22
+ noopPermissionService,
23
+ type CreatePermissionServiceOptions,
24
+ type PermissionService,
25
+ type UnknownPermissionWarning,
26
+ } from './permissions'
27
+ export { grantRole, type GrantOptions, type GrantResult } from './grant'
28
+ export { matchesOrigin, type MatchableOrigin } from './resolve'
29
+ export { MATCH_RULE_JSON_SCHEMA_PATTERN, rolesConfigSchema, type RoleConfig, type RolesConfig } from './schema'
@@ -0,0 +1,305 @@
1
+ // Compact string DSL for permissions match rules. Examples:
2
+ //
3
+ // tui # any TUI session
4
+ // cron # any cron session (resolved by provenance, not match)
5
+ // subagent # any subagent session
6
+ // subagent:memory-logger # specific subagent
7
+ // * # any channel session, any platform
8
+ // slack:* # any Slack chat, any workspace
9
+ // slack:T0123 # one Slack workspace, any chat
10
+ // slack:T0123/C0ABCDE # one specific Slack chat
11
+ // slack:dm/* # any Slack DM
12
+ // slack:T0123 author:U_ME # specific author across workspace
13
+ //
14
+ // Within one rule: all tokens AND'd. Across multiple rules: OR'd. The parser
15
+ // is hand-rolled (not regex) because the rejection table demands precise
16
+ // error messages with typo suggestions; a single big regex would only ever
17
+ // say "didn't match".
18
+
19
+ export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao'] as const
20
+ export type Platform = (typeof PLATFORMS)[number]
21
+
22
+ const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
23
+
24
+ export type MatchRule =
25
+ | { kind: 'tui' }
26
+ | { kind: 'cron' }
27
+ | { kind: 'subagent'; subagent?: string }
28
+ | { kind: 'wildcard' }
29
+ | {
30
+ kind: 'channel'
31
+ platform: Platform
32
+ // undefined when the rule wildcards across the whole platform (e.g. `slack:*`).
33
+ // '*' is never stored — it is collapsed to undefined at parse time so
34
+ // matchers stay shape-pure (presence == specificity).
35
+ workspace?: string
36
+ chat?: string
37
+ // Buckets for DM-style scopes. `slack:dm/*`, `discord:dm/*`,
38
+ // `kakao:dm/*`, `kakao:group/*`, `kakao:open/*` produce `bucket` only
39
+ // (no workspace, no chat).
40
+ bucket?: 'dm' | 'group' | 'open'
41
+ author?: string
42
+ }
43
+
44
+ export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false; error: string }
45
+
46
+ // Regex used by the JSON Schema layer for editor-time validation. Kept here
47
+ // next to the parser so divergence is harder. Deliberately permissive: it
48
+ // matches any `<name>:<value>` qualifier shape so the parser still gets to
49
+ // run and emit typo suggestions like `autor:` -> `author:`. If we tightened
50
+ // this to `author:` only, the JSON schema would reject typos with a generic
51
+ // "did not match pattern" error and the user would lose the actionable hint.
52
+ export const MATCH_RULE_REGEX_SOURCE =
53
+ '^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
54
+
55
+ export function parseMatchRule(input: string): ParseMatchRuleResult {
56
+ if (input !== input.trim() || input.length === 0) {
57
+ return { ok: false, error: 'match rule must not have leading or trailing whitespace' }
58
+ }
59
+
60
+ // The DSL allows ONLY single literal spaces as token separators. Any
61
+ // other whitespace (tabs, newlines, CR, vertical tab, NBSP, NUL, etc.)
62
+ // inside a rule is rejected -- both because the JSON Schema regex uses
63
+ // `\s` boundaries that would diverge from this parser otherwise, and
64
+ // because workspace/chat IDs containing whitespace are not a legitimate
65
+ // shape on any supported platform.
66
+ if (/[^\S ]|\u0000/.test(input)) {
67
+ return {
68
+ ok: false,
69
+ error: 'match rule must use only single ASCII spaces; no tabs, newlines, or control characters',
70
+ }
71
+ }
72
+ const tokens = input.split(' ')
73
+ if (tokens.some((t) => t.length === 0)) {
74
+ return { ok: false, error: 'match rule must use exactly one space between tokens' }
75
+ }
76
+ const [scope, ...qualifiers] = tokens
77
+ if (scope === undefined) return { ok: false, error: 'match rule is empty' }
78
+
79
+ const qualifierResult = parseQualifiers(qualifiers)
80
+ if (!qualifierResult.ok) return { ok: false, error: qualifierResult.error }
81
+ const { author } = qualifierResult.value
82
+
83
+ // Reject legacy shorthand prefixes with a hint to the canonical form.
84
+ const legacy = LEGACY_PREFIXES.find((p) => scope === p.from || scope.startsWith(`${p.from}:`))
85
+ if (legacy) {
86
+ const replaced = scope === legacy.from ? legacy.to : `${legacy.to}${scope.slice(legacy.from.length)}`
87
+ return {
88
+ ok: false,
89
+ error: `legacy prefix '${legacy.from}'; use '${replaced}' instead`,
90
+ }
91
+ }
92
+
93
+ // Top-level keyword scopes.
94
+ if (scope === 'tui') {
95
+ if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a channel scope" }
96
+ return { ok: true, value: { kind: 'tui' } }
97
+ }
98
+ if (scope === 'cron') {
99
+ if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a channel scope" }
100
+ return { ok: true, value: { kind: 'cron' } }
101
+ }
102
+ if (scope === 'subagent' || scope.startsWith('subagent:')) {
103
+ if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a channel scope" }
104
+ if (scope === 'subagent') return { ok: true, value: { kind: 'subagent' } }
105
+ const name = scope.slice('subagent:'.length)
106
+ if (!SUBAGENT_NAME.test(name)) {
107
+ return { ok: false, error: `subagent name '${name}' must match ${SUBAGENT_NAME.source}` }
108
+ }
109
+ return { ok: true, value: { kind: 'subagent', subagent: name } }
110
+ }
111
+ if (scope === '*') {
112
+ if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a specific channel scope" }
113
+ return { ok: true, value: { kind: 'wildcard' } }
114
+ }
115
+
116
+ // Channel scopes: `<platform>:<rest>`.
117
+ const colon = scope.indexOf(':')
118
+ if (colon === -1) {
119
+ return { ok: false, error: suggestUnknownScope(scope) }
120
+ }
121
+ const prefix = scope.slice(0, colon)
122
+ const rest = scope.slice(colon + 1)
123
+ if (!(PLATFORMS as readonly string[]).includes(prefix)) {
124
+ return { ok: false, error: suggestUnknownScope(scope) }
125
+ }
126
+ const platform = prefix as Platform
127
+
128
+ return parseChannelScope(platform, rest, author)
129
+ }
130
+
131
+ function parseChannelScope(platform: Platform, rest: string, author: string | undefined): ParseMatchRuleResult {
132
+ if (rest.length === 0) {
133
+ return { ok: false, error: `channel scope '${platform}:' is missing a coordinate` }
134
+ }
135
+
136
+ // Wildcards and redundant forms.
137
+ if (rest === '*') {
138
+ return { ok: true, value: buildChannelRule(platform, { author }) }
139
+ }
140
+
141
+ // Bucket scopes: `dm/*`, `dm/<id>`, `group/*`, `open/*`. Slack's `im` is
142
+ // renamed to `dm`; that mapping is enforced by the legacy-prefix table at
143
+ // the top of parseMatchRule for unprefixed forms — here we just refuse the
144
+ // bare `im` bucket.
145
+ const slash = rest.indexOf('/')
146
+ if (slash !== -1) {
147
+ const head = rest.slice(0, slash)
148
+ const tail = rest.slice(slash + 1)
149
+
150
+ if (head === 'im') {
151
+ return { ok: false, error: `bucket 'im' renamed; use '${platform}:dm/${tail}'` }
152
+ }
153
+
154
+ if (head === '*') {
155
+ // `slack:*/...` is always wrong — a chat ID is workspace-scoped, so any
156
+ // concrete chat ID under a wildcard workspace is logically impossible.
157
+ // Even `slack:*/*` simplifies to `slack:*`.
158
+ const suggestion = tail === '*' ? `${platform}:*` : `${platform}:*`
159
+ return {
160
+ ok: false,
161
+ error: `wildcard workspace combined with '/${tail}' is nonsensical; use '${suggestion}'`,
162
+ }
163
+ }
164
+
165
+ if (head === 'dm' || head === 'group' || head === 'open') {
166
+ if (platform !== 'kakao' && (head === 'group' || head === 'open')) {
167
+ return { ok: false, error: `bucket '${head}' is only valid for kakao` }
168
+ }
169
+ if (tail === '') {
170
+ return { ok: false, error: `bucket '${platform}:${head}/' requires '*' or a chat id` }
171
+ }
172
+ if (tail === '*') {
173
+ return { ok: true, value: buildChannelRule(platform, { bucket: head as 'dm' | 'group' | 'open', author }) }
174
+ }
175
+ // `slack:dm/<id>` — keep the bucket plus the specific chat. We omit a
176
+ // separate workspace field; DM IDs are globally unique within a
177
+ // platform's adapter.
178
+ return {
179
+ ok: true,
180
+ value: buildChannelRule(platform, {
181
+ bucket: head as 'dm' | 'group' | 'open',
182
+ chat: tail,
183
+ author,
184
+ }),
185
+ }
186
+ }
187
+
188
+ // `slack:T0123/*` is redundant — drop the trailing `/*`.
189
+ if (tail === '*') {
190
+ return { ok: false, error: `trailing '/*' is redundant; use '${platform}:${head}'` }
191
+ }
192
+ // `slack:T0123/C0ABCDE` — workspace + chat.
193
+ return {
194
+ ok: true,
195
+ value: buildChannelRule(platform, { workspace: head, chat: tail, author }),
196
+ }
197
+ }
198
+
199
+ // No slash: `slack:T0123` or `kakao:dm` (bare bucket — error).
200
+ if (rest === 'dm' || rest === 'group' || rest === 'open') {
201
+ return { ok: false, error: `bucket '${platform}:${rest}' requires a chat id or '*'` }
202
+ }
203
+ return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
204
+ }
205
+
206
+ function buildChannelRule(
207
+ platform: Platform,
208
+ parts: {
209
+ workspace?: string
210
+ chat?: string
211
+ bucket?: 'dm' | 'group' | 'open'
212
+ author?: string
213
+ },
214
+ ): MatchRule {
215
+ const rule: MatchRule = { kind: 'channel', platform }
216
+ if (parts.workspace !== undefined) rule.workspace = parts.workspace
217
+ if (parts.chat !== undefined) rule.chat = parts.chat
218
+ if (parts.bucket !== undefined) rule.bucket = parts.bucket
219
+ if (parts.author !== undefined) rule.author = parts.author
220
+ return rule
221
+ }
222
+
223
+ type ParsedQualifiers = { author?: string }
224
+ function parseQualifiers(qualifiers: string[]): { ok: true; value: ParsedQualifiers } | { ok: false; error: string } {
225
+ const out: ParsedQualifiers = {}
226
+ for (const token of qualifiers) {
227
+ const eq = token.indexOf(':')
228
+ if (eq === -1) {
229
+ return { ok: false, error: `qualifier '${token}' must have form '<name>:<value>'` }
230
+ }
231
+ const name = token.slice(0, eq)
232
+ const value = token.slice(eq + 1)
233
+ if (value.length === 0) {
234
+ return { ok: false, error: `qualifier '${name}:' must have a value` }
235
+ }
236
+ if (name === 'author') {
237
+ if (out.author !== undefined) {
238
+ return { ok: false, error: `qualifier 'author:' may not appear more than once in a single rule` }
239
+ }
240
+ out.author = value
241
+ continue
242
+ }
243
+ return { ok: false, error: `unknown qualifier '${name}:'.${suggestQualifier(name)}` }
244
+ }
245
+ return { ok: true, value: out }
246
+ }
247
+
248
+ // Empirical typo distance: ed1 on the scope keyword catches `tem:` → `team:`
249
+ // (which is itself a legacy form), `slak:` → `slack:`, etc.
250
+ function suggestUnknownScope(scope: string): string {
251
+ const head = scope.split(':')[0] ?? scope
252
+ const candidates = ['tui', 'cron', 'subagent', ...PLATFORMS, '*']
253
+ const hit = closestEd1(head, candidates)
254
+ if (hit !== null) {
255
+ return `unknown scope '${scope}'; did you mean '${hit}'?`
256
+ }
257
+ return `unknown scope '${scope}'; expected one of: tui, cron, subagent, *, ${PLATFORMS.join(', ')}`
258
+ }
259
+
260
+ function suggestQualifier(name: string): string {
261
+ const hit = closestEd1(name, ['author'])
262
+ return hit !== null ? ` Did you mean '${hit}:'?` : ''
263
+ }
264
+
265
+ function closestEd1(input: string, candidates: readonly string[]): string | null {
266
+ for (const c of candidates) {
267
+ if (editDistanceAtMost1(input, c)) return c
268
+ }
269
+ return null
270
+ }
271
+
272
+ function editDistanceAtMost1(a: string, b: string): boolean {
273
+ if (a === b) return true
274
+ const la = a.length
275
+ const lb = b.length
276
+ if (Math.abs(la - lb) > 1) return false
277
+ let i = 0
278
+ let j = 0
279
+ let edits = 0
280
+ while (i < la && j < lb) {
281
+ if (a[i] === b[j]) {
282
+ i++
283
+ j++
284
+ continue
285
+ }
286
+ edits++
287
+ if (edits > 1) return false
288
+ if (la === lb) {
289
+ i++
290
+ j++
291
+ } else if (la > lb) {
292
+ i++
293
+ } else {
294
+ j++
295
+ }
296
+ }
297
+ if (i < la || j < lb) edits++
298
+ return edits <= 1
299
+ }
300
+
301
+ const LEGACY_PREFIXES: { from: string; to: string }[] = [
302
+ { from: 'team', to: 'slack' },
303
+ { from: 'guild', to: 'discord' },
304
+ { from: 'tg', to: 'telegram' },
305
+ ]