typeclaw 0.1.5 → 0.2.0

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 (128) hide show
  1. package/README.md +14 -12
  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 +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  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 +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  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 +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +209 -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 +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +190 -61
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -0,0 +1,156 @@
1
+ import { intro, note, outro, spinner } from '@clack/prompts'
2
+ import { defineCommand } from 'citty'
3
+
4
+ import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
5
+ import { findAgentDir } from '@/init'
6
+ import { runClaimSession } from '@/role-claim'
7
+
8
+ import { c, errorLine } from './ui'
9
+
10
+ const claimSub = defineCommand({
11
+ meta: {
12
+ name: 'claim',
13
+ description:
14
+ 'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you DM that code back to the bot to prove control of the channel account.',
15
+ },
16
+ args: {
17
+ as: {
18
+ type: 'string',
19
+ description: 'which role to claim (owner | member | trusted | <custom>)',
20
+ default: 'owner',
21
+ },
22
+ channel: {
23
+ type: 'string',
24
+ description: 'restrict the claim to one channel adapter (slack-bot | discord-bot | telegram-bot | kakaotalk)',
25
+ },
26
+ ttl: {
27
+ type: 'string',
28
+ description: 'how long the code stays valid, in milliseconds',
29
+ default: '600000',
30
+ },
31
+ url: {
32
+ type: 'string',
33
+ description: 'agent websocket url (defaults to ws://127.0.0.1:<host port> discovered from the running container)',
34
+ },
35
+ },
36
+ async run({ args }) {
37
+ const url = args.url ?? (await defaultUrl())
38
+ const ttlMs = Number(args.ttl)
39
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
40
+ console.error(errorLine(`--ttl must be a positive integer (milliseconds); got "${args.ttl}"`))
41
+ process.exit(1)
42
+ }
43
+
44
+ intro(`Claiming "${args.as}" role`)
45
+
46
+ const s = spinner()
47
+ s.start('Waiting for code...')
48
+
49
+ let started = false
50
+ const result = await runClaimSession({
51
+ url,
52
+ role: args.as,
53
+ ttlMs,
54
+ ...(args.channel !== undefined ? { channel: args.channel } : {}),
55
+ onStarted: (payload) => {
56
+ started = true
57
+ s.stop('Ready.')
58
+ const expiresInSec = Math.max(0, Math.round((payload.expiresAt - Date.now()) / 1000))
59
+ const lines = [
60
+ `Send this message to your bot as a DM:`,
61
+ '',
62
+ ` ${c.bold(payload.code)}`,
63
+ '',
64
+ `(expires in ${formatDuration(expiresInSec)})`,
65
+ ]
66
+ note(lines.join('\n'), 'Claim code')
67
+ const waitMsg =
68
+ payload.channel !== undefined ? `Listening on ${payload.channel}...` : 'Listening on all wired channels...'
69
+ s.start(waitMsg)
70
+ },
71
+ })
72
+
73
+ if (!started) {
74
+ s.stop('Failed to start claim session.')
75
+ }
76
+
77
+ if (result.kind === 'completed') {
78
+ s.stop(c.green(`Paired as ${result.payload.role}.`))
79
+ outro(`Match rule added: ${c.bold(result.payload.matchRule)}`)
80
+ return
81
+ }
82
+ if (result.kind === 'error') {
83
+ s.stop(c.red(`Claim failed: ${result.payload.reason}`))
84
+ process.exit(1)
85
+ }
86
+ s.stop(c.red(`Claim timed out. Run "typeclaw role claim" again to retry.`))
87
+ process.exit(1)
88
+ },
89
+ })
90
+
91
+ const listSub = defineCommand({
92
+ meta: {
93
+ name: 'list',
94
+ description: 'show the roles declared on this agent (typeclaw.json#roles)',
95
+ },
96
+ async run() {
97
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
98
+ const { loadConfigSync } = await import('@/config')
99
+ const config = loadConfigSync(cwd)
100
+ if (!config.roles || Object.keys(config.roles).length === 0) {
101
+ console.log(c.dim('No roles declared. Run `typeclaw role claim` to add one, or edit typeclaw.json by hand.'))
102
+ return
103
+ }
104
+ for (const [name, role] of Object.entries(config.roles)) {
105
+ console.log(c.bold(name))
106
+ if (role.match.length === 0) {
107
+ console.log(` ${c.dim('(no match rules)')}`)
108
+ }
109
+ for (const rule of role.match) {
110
+ console.log(` match: ${describeRule(rule)}`)
111
+ }
112
+ if (role.permissions !== undefined) {
113
+ for (const perm of role.permissions) {
114
+ console.log(` permission: ${perm}`)
115
+ }
116
+ }
117
+ }
118
+ },
119
+ })
120
+
121
+ export const roleCommand = defineCommand({
122
+ meta: {
123
+ name: 'role',
124
+ description: 'manage role memberships on this agent',
125
+ },
126
+ subCommands: {
127
+ claim: () => Promise.resolve(claimSub),
128
+ list: () => Promise.resolve(listSub),
129
+ },
130
+ })
131
+
132
+ async function defaultUrl(): Promise<string> {
133
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
134
+ const precheck = await requireContainerRunning({ cwd })
135
+ if (!precheck.ok) {
136
+ console.error(errorLine(precheck.reason))
137
+ process.exit(1)
138
+ }
139
+ const port = await resolveHostPort({ cwd })
140
+ const token = await resolveTuiToken({ cwd })
141
+ const url = new URL(`ws://127.0.0.1:${port}`)
142
+ if (token !== null) url.searchParams.set('token', token)
143
+ return url.toString()
144
+ }
145
+
146
+ function formatDuration(sec: number): string {
147
+ if (sec < 60) return `${sec}s`
148
+ const m = Math.floor(sec / 60)
149
+ const s = sec % 60
150
+ if (s === 0) return `${m}m`
151
+ return `${m}m ${s}s`
152
+ }
153
+
154
+ function describeRule(rule: unknown): string {
155
+ return JSON.stringify(rule)
156
+ }
package/src/cli/run.ts CHANGED
@@ -4,6 +4,8 @@ import { CONTAINER_PORT } from '@/container'
4
4
  import { isInitialized } from '@/init'
5
5
  import { startAgent } from '@/run'
6
6
 
7
+ import { errorLine } from './ui'
8
+
7
9
  export const run = defineCommand({
8
10
  meta: {
9
11
  name: 'run',
@@ -31,7 +33,7 @@ export const run = defineCommand({
31
33
  },
32
34
  async run({ args }) {
33
35
  if (!isInitialized(process.cwd())) {
34
- console.error('TypeClaw config file not found. Run `typeclaw init` first.')
36
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first.'))
35
37
  process.exit(1)
36
38
  }
37
39
 
package/src/cli/tui.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { resolveHostPort, resolveTuiToken } from '@/container'
3
+ import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
  import { createTui } from '@/tui'
6
6
 
7
+ import { errorLine } from './ui'
8
+
7
9
  export const tui = defineCommand({
8
10
  meta: {
9
11
  name: 'tui',
@@ -30,6 +32,11 @@ export const tui = defineCommand({
30
32
 
31
33
  async function defaultUrl(): Promise<string> {
32
34
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
35
+ const precheck = await requireContainerRunning({ cwd })
36
+ if (!precheck.ok) {
37
+ console.error(errorLine(precheck.reason))
38
+ process.exit(1)
39
+ }
33
40
  const port = await resolveHostPort({ cwd })
34
41
  const token = await resolveTuiToken({ cwd })
35
42
  const url = new URL(`ws://127.0.0.1:${port}`)
@@ -0,0 +1,47 @@
1
+ import { startOfDaysAgo, startOfToday } from '@/usage'
2
+
3
+ export const USAGE_COMMON_ARGS = {
4
+ json: {
5
+ type: 'boolean' as const,
6
+ description: 'emit the usage report as JSON',
7
+ default: false,
8
+ },
9
+ since: {
10
+ type: 'string' as const,
11
+ description: "ISO date or relative duration ('today', '7d', '30d')",
12
+ },
13
+ until: {
14
+ type: 'string' as const,
15
+ description: 'ISO date upper bound (exclusive)',
16
+ },
17
+ }
18
+
19
+ export function parseSince(value: unknown, command: string): number | undefined {
20
+ if (value === undefined || value === null) return undefined
21
+ if (typeof value !== 'string' || value.length === 0) return undefined
22
+ if (value === 'today') return startOfToday()
23
+ const days = /^(\d+)d$/.exec(value)
24
+ if (days) {
25
+ const n = Number(days[1])
26
+ if (n <= 0) exitInvalid(command, 'since', value, "duration must be at least 1 day (e.g. '1d', '7d')")
27
+ // `Nd` means "last N calendar days INCLUDING today" → window starts
28
+ // midnight (N-1) days before today.
29
+ return startOfDaysAgo(n - 1)
30
+ }
31
+ const ms = Date.parse(value)
32
+ if (Number.isFinite(ms)) return ms
33
+ exitInvalid(command, 'since', value, "expected ISO date, 'today', or '<n>d' (e.g. 7d)")
34
+ }
35
+
36
+ export function parseUntil(value: unknown, command: string): number | undefined {
37
+ if (value === undefined || value === null) return undefined
38
+ if (typeof value !== 'string' || value.length === 0) return undefined
39
+ const ms = Date.parse(value)
40
+ if (Number.isFinite(ms)) return ms
41
+ exitInvalid(command, 'until', value, 'expected ISO date (e.g. 2026-05-01)')
42
+ }
43
+
44
+ export function exitInvalid(command: string, flag: string, value: string, hint: string): never {
45
+ process.stderr.write(`${command}: invalid --${flag} value "${value}"; ${hint}\n`)
46
+ process.exit(2)
47
+ }
@@ -0,0 +1,97 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { findAgentDir } from '@/init'
4
+ import { runUsage } from '@/usage'
5
+ import { formatJson, formatReport } from '@/usage/report'
6
+
7
+ import { parseSince, parseUntil, USAGE_COMMON_ARGS } from './usage-args'
8
+
9
+ const SUBCOMMANDS = ['daily', 'session', 'models'] as const
10
+ type Subcommand = (typeof SUBCOMMANDS)[number]
11
+ type View = 'summary' | Subcommand
12
+
13
+ const COMMON_ARGS = {
14
+ ...USAGE_COMMON_ARGS,
15
+ cwd: {
16
+ type: 'string' as const,
17
+ description: 'override the agent folder',
18
+ },
19
+ }
20
+
21
+ const subcommand = (view: View, description: string) =>
22
+ defineCommand({
23
+ meta: { name: view, description },
24
+ args: {
25
+ ...COMMON_ARGS,
26
+ ...(view === 'session' ? { limit: { type: 'string' as const, description: 'max sessions (default 20)' } } : {}),
27
+ },
28
+ async run({ args }) {
29
+ await emit(view, args)
30
+ },
31
+ })
32
+
33
+ export const usageCommand = defineCommand({
34
+ meta: {
35
+ name: 'usage',
36
+ description: 'report LLM token usage and cost for this agent folder',
37
+ },
38
+ args: COMMON_ARGS,
39
+ subCommands: {
40
+ daily: subcommand('daily', 'one row per calendar day'),
41
+ session: subcommand('session', 'top sessions by cost'),
42
+ models: subcommand('models', 'one row per provider/model'),
43
+ },
44
+ async run({ args }) {
45
+ // citty invokes both the matched subcommand's `run` and the parent's
46
+ // `run`. Suppress the summary when a subcommand was dispatched.
47
+ const first = args._?.[0]
48
+ if (typeof first === 'string' && (SUBCOMMANDS as readonly string[]).includes(first)) return
49
+ await emit('summary', args)
50
+ },
51
+ })
52
+
53
+ async function emit(view: View, args: Record<string, unknown>): Promise<void> {
54
+ const cwdArg = typeof args.cwd === 'string' && args.cwd.length > 0 ? args.cwd : process.cwd()
55
+ const agentDir = findAgentDir(cwdArg) ?? cwdArg
56
+ const since = parseSince(args.since, 'typeclaw usage')
57
+ const until = parseUntil(args.until, 'typeclaw usage')
58
+ const limit = parseLimit(args.limit)
59
+
60
+ const report = await runUsage({
61
+ agentDir,
62
+ ...(since !== undefined ? { since } : {}),
63
+ ...(until !== undefined ? { until } : {}),
64
+ })
65
+
66
+ if (args.json === true) {
67
+ process.stdout.write(`${formatJson(report)}\n`)
68
+ return
69
+ }
70
+
71
+ const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
72
+ const terminalWidth = resolveTerminalWidth()
73
+ const text = formatReport(report, {
74
+ useColor,
75
+ view,
76
+ ...(terminalWidth !== undefined ? { terminalWidth } : {}),
77
+ ...(limit !== undefined ? { limit } : {}),
78
+ })
79
+ process.stdout.write(`${text}\n`)
80
+ }
81
+
82
+ function resolveTerminalWidth(): number | undefined {
83
+ // process.stdout.columns is undefined when stdout is not a TTY (piped to
84
+ // less/grep/file). Fall back to $COLUMNS so users can force a width when
85
+ // piping, and so tests with an inherited COLUMNS env var see the override.
86
+ if (process.stdout.columns !== undefined) return process.stdout.columns
87
+ const env = process.env.COLUMNS
88
+ if (env === undefined || env === '') return undefined
89
+ const n = Number(env)
90
+ return Number.isFinite(n) && n > 0 ? n : undefined
91
+ }
92
+
93
+ function parseLimit(value: unknown): number | undefined {
94
+ if (typeof value !== 'string') return undefined
95
+ const n = Number(value)
96
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined
97
+ }
@@ -31,3 +31,4 @@ export {
31
31
  type ComposeStopResult,
32
32
  type StopSuccess,
33
33
  } from './stop'
34
+ export { composeUsage, type ComposeUsageEvent, type ComposeUsageOptions, type ComposeUsageResult } from './usage'
@@ -0,0 +1,65 @@
1
+ import { runUsage, type UsageReport } from '@/usage'
2
+
3
+ import { discoverAgents, type AgentEntry } from './discover'
4
+ import type { AgentResult } from './start'
5
+
6
+ export type ComposeUsageEvent =
7
+ | { kind: 'agent-start'; name: string }
8
+ | { kind: 'agent-done'; name: string; result: AgentResult<UsageReport> }
9
+
10
+ export type ComposeUsageOptions = {
11
+ rootCwd: string
12
+ since?: number
13
+ until?: number
14
+ onProgress?: (event: ComposeUsageEvent) => void
15
+ }
16
+
17
+ export type ComposeUsageResult = {
18
+ rootCwd: string
19
+ range: { since: number | null; until: number | null }
20
+ agents: AgentEntry[]
21
+ results: AgentResult<UsageReport>[]
22
+ }
23
+
24
+ // Fans out `runUsage` across every typeclaw agent in `rootCwd`'s immediate
25
+ // subdirectories. Per-agent failures are captured as `AgentResult` rather than
26
+ // thrown — one corrupt sessions/ dir shouldn't blank out the whole report.
27
+ export async function composeUsage({
28
+ rootCwd,
29
+ since,
30
+ until,
31
+ onProgress,
32
+ }: ComposeUsageOptions): Promise<ComposeUsageResult> {
33
+ const agents = discoverAgents(rootCwd)
34
+ const results = await Promise.all(
35
+ agents.map(async (agent): Promise<AgentResult<UsageReport>> => {
36
+ onProgress?.({ kind: 'agent-start', name: agent.name })
37
+ const result = await runOne(agent, since, until)
38
+ onProgress?.({ kind: 'agent-done', name: agent.name, result })
39
+ return result
40
+ }),
41
+ )
42
+ return {
43
+ rootCwd,
44
+ range: { since: since ?? null, until: until ?? null },
45
+ agents,
46
+ results,
47
+ }
48
+ }
49
+
50
+ async function runOne(
51
+ agent: AgentEntry,
52
+ since: number | undefined,
53
+ until: number | undefined,
54
+ ): Promise<AgentResult<UsageReport>> {
55
+ try {
56
+ const report = await runUsage({
57
+ agentDir: agent.cwd,
58
+ ...(since !== undefined ? { since } : {}),
59
+ ...(until !== undefined ? { until } : {}),
60
+ })
61
+ return { name: agent.name, ok: true, data: report }
62
+ } catch (error) {
63
+ return { name: agent.name, ok: false, reason: error instanceof Error ? error.message : String(error) }
64
+ }
65
+ }