typeclaw 0.1.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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { colors } from './theme'
|
|
2
|
+
|
|
3
|
+
const ARGS_PREVIEW_MAX = 200
|
|
4
|
+
const RESULT_PREVIEW_MAX = 400
|
|
5
|
+
|
|
6
|
+
export function formatToolStart(name: string, args: unknown): string {
|
|
7
|
+
const head = `${colors.cyan('●')} ${colors.bold(name)}`
|
|
8
|
+
const preview = previewArgs(name, args)
|
|
9
|
+
return preview === null ? head : `${head} ${colors.dim(preview)}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatToolEnd(name: string, error: boolean, result: unknown, durationMs: number): string {
|
|
13
|
+
const glyph = error ? colors.red('✗') : colors.green('✓')
|
|
14
|
+
const dur = colors.gray(formatDuration(durationMs))
|
|
15
|
+
const head = `${glyph} ${colors.bold(name)} ${dur}`
|
|
16
|
+
const preview = previewResult(name, error, result)
|
|
17
|
+
return preview === null ? head : `${head}\n${preview}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatUserPromptHistory(text: string): string {
|
|
21
|
+
return stripHiddenBlocks(text)
|
|
22
|
+
.split('\n')
|
|
23
|
+
.map((line, idx) => `${colors.dim(idx === 0 ? '>' : '.')} ${line}`)
|
|
24
|
+
.join('\n')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripHiddenBlocks(text: string): string {
|
|
28
|
+
return text.replace(/<hatching>[\s\S]*?<\/hatching>\s*/g, '').trimStart()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatQueuePanel(items: ReadonlyArray<{ text: string }>): string {
|
|
32
|
+
return items.map((item) => `${colors.dim('[QUEUED]')} ${firstLine(item.text)}`).join('\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function firstLine(text: string): string {
|
|
36
|
+
const idx = text.indexOf('\n')
|
|
37
|
+
if (idx === -1) return text
|
|
38
|
+
const head = text.slice(0, idx)
|
|
39
|
+
const remaining = text.length - idx
|
|
40
|
+
return `${head} ${colors.dim(`(+${remaining} chars)`)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Tool-specific argument summaries. Each humanizer collapses the typical
|
|
44
|
+
// `{path, pattern, command, …}` parameter object into a single line that
|
|
45
|
+
// reads naturally next to the tool name. Returning null means "I don't
|
|
46
|
+
// recognize this shape, fall back to the generic compact JSON".
|
|
47
|
+
type ArgRecord = Record<string, unknown>
|
|
48
|
+
|
|
49
|
+
function humanizeArgs(name: string, args: unknown): string | null {
|
|
50
|
+
if (!isObject(args)) return null
|
|
51
|
+
switch (name) {
|
|
52
|
+
case 'read':
|
|
53
|
+
return humanizeReadArgs(args)
|
|
54
|
+
case 'bash':
|
|
55
|
+
return humanizeBashArgs(args)
|
|
56
|
+
case 'edit':
|
|
57
|
+
return humanizeEditArgs(args)
|
|
58
|
+
case 'write':
|
|
59
|
+
return humanizeWriteArgs(args)
|
|
60
|
+
case 'grep':
|
|
61
|
+
return humanizeGrepArgs(args)
|
|
62
|
+
case 'find':
|
|
63
|
+
return humanizeFindArgs(args)
|
|
64
|
+
case 'ls':
|
|
65
|
+
return humanizeLsArgs(args)
|
|
66
|
+
case 'websearch':
|
|
67
|
+
return humanizeWebsearchArgs(args)
|
|
68
|
+
case 'webfetch':
|
|
69
|
+
return humanizeWebfetchArgs(args)
|
|
70
|
+
default:
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function humanizeReadArgs(args: ArgRecord): string | null {
|
|
76
|
+
const path = asString(args.path)
|
|
77
|
+
if (path === null) return null
|
|
78
|
+
const offset = asNumber(args.offset)
|
|
79
|
+
const limit = asNumber(args.limit)
|
|
80
|
+
if (offset !== null && limit !== null) return `${path} (lines ${offset}-${offset + limit - 1})`
|
|
81
|
+
if (offset !== null) return `${path} (from line ${offset})`
|
|
82
|
+
if (limit !== null) return `${path} (first ${limit} lines)`
|
|
83
|
+
return path
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function humanizeBashArgs(args: ArgRecord): string | null {
|
|
87
|
+
const command = asString(args.command)
|
|
88
|
+
if (command === null) return null
|
|
89
|
+
return command.replace(/\s+/g, ' ').trim()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function humanizeEditArgs(args: ArgRecord): string | null {
|
|
93
|
+
const path = asString(args.path)
|
|
94
|
+
if (path === null) return null
|
|
95
|
+
const edits = Array.isArray(args.edits) ? args.edits.length : 0
|
|
96
|
+
return edits > 0 ? `${path} (${edits} edit${edits === 1 ? '' : 's'})` : path
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function humanizeWriteArgs(args: ArgRecord): string | null {
|
|
100
|
+
const path = asString(args.path)
|
|
101
|
+
if (path === null) return null
|
|
102
|
+
const content = asString(args.content)
|
|
103
|
+
if (content === null) return path
|
|
104
|
+
const bytes = Buffer.byteLength(content, 'utf-8')
|
|
105
|
+
return `${path} (${formatBytes(bytes)})`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function humanizeGrepArgs(args: ArgRecord): string | null {
|
|
109
|
+
const pattern = asString(args.pattern)
|
|
110
|
+
if (pattern === null) return null
|
|
111
|
+
const where = asString(args.path) ?? asString(args.glob)
|
|
112
|
+
return where ? `"${pattern}" in ${where}` : `"${pattern}"`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function humanizeFindArgs(args: ArgRecord): string | null {
|
|
116
|
+
const pattern = asString(args.pattern)
|
|
117
|
+
if (pattern === null) return null
|
|
118
|
+
const where = asString(args.path)
|
|
119
|
+
return where ? `${pattern} in ${where}` : pattern
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function humanizeLsArgs(args: ArgRecord): string | null {
|
|
123
|
+
return asString(args.path) ?? '.'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function humanizeWebsearchArgs(args: ArgRecord): string | null {
|
|
127
|
+
const query = asString(args.query)
|
|
128
|
+
if (query === null) return null
|
|
129
|
+
const source = asString(args.source)
|
|
130
|
+
return source && source !== 'web' ? `"${query}" (${source})` : `"${query}"`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function humanizeWebfetchArgs(args: ArgRecord): string | null {
|
|
134
|
+
return asString(args.url)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Tool-specific result enrichments. Most tools already embed a human-readable
|
|
138
|
+
// summary in `content[].text`, so the default path simply extracts that. The
|
|
139
|
+
// exceptions: `edit` benefits from showing the diff, `bash` likes a footer
|
|
140
|
+
// with truncation/full-output info, image reads collapse to `[image]`.
|
|
141
|
+
function humanizeResult(name: string, result: unknown): string | null {
|
|
142
|
+
if (!isObject(result)) return null
|
|
143
|
+
const enriched = enrichResult(name, result)
|
|
144
|
+
if (enriched !== null) return enriched
|
|
145
|
+
return extractContentText(result)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function enrichResult(name: string, result: ArgRecord): string | null {
|
|
149
|
+
switch (name) {
|
|
150
|
+
case 'edit':
|
|
151
|
+
return enrichEditResult(result)
|
|
152
|
+
case 'bash':
|
|
153
|
+
return enrichBashResult(result)
|
|
154
|
+
case 'read':
|
|
155
|
+
return enrichReadResult(result)
|
|
156
|
+
case 'websearch':
|
|
157
|
+
return enrichWebsearchResult(result)
|
|
158
|
+
default:
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function enrichEditResult(result: ArgRecord): string | null {
|
|
164
|
+
const details = isObject(result.details) ? result.details : null
|
|
165
|
+
const diff = details ? asString(details.diff) : null
|
|
166
|
+
if (diff !== null) return diff
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function enrichBashResult(result: ArgRecord): string | null {
|
|
171
|
+
const text = extractContentText(result)
|
|
172
|
+
if (text === null) return null
|
|
173
|
+
const details = isObject(result.details) ? result.details : null
|
|
174
|
+
const fullOutput = details ? asString(details.fullOutputPath) : null
|
|
175
|
+
if (fullOutput === null) return text
|
|
176
|
+
return `${text}\n\nFull output saved to: ${fullOutput}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function enrichReadResult(result: ArgRecord): string | null {
|
|
180
|
+
const content = Array.isArray(result.content) ? result.content : null
|
|
181
|
+
if (content === null) return null
|
|
182
|
+
const hasImage = content.some((part) => isObject(part) && part.type === 'image')
|
|
183
|
+
if (!hasImage) return null
|
|
184
|
+
const mime = content
|
|
185
|
+
.map((part) => (isObject(part) && part.type === 'image' ? asString(part.mimeType) : null))
|
|
186
|
+
.find((m) => m !== null)
|
|
187
|
+
return mime ? `[image: ${mime}]` : '[image]'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function enrichWebsearchResult(result: ArgRecord): string | null {
|
|
191
|
+
const details = isObject(result.details) ? result.details : null
|
|
192
|
+
if (details === null) return null
|
|
193
|
+
const results = Array.isArray(details.results) ? details.results : null
|
|
194
|
+
if (results === null || results.length === 0) {
|
|
195
|
+
return extractContentText(result)
|
|
196
|
+
}
|
|
197
|
+
const query = asString(details.query) ?? ''
|
|
198
|
+
const source = asString(details.source) ?? ''
|
|
199
|
+
const header = query ? `${results.length} result${results.length === 1 ? '' : 's'} for "${query}" (${source})` : null
|
|
200
|
+
const lines = results
|
|
201
|
+
.map((entry, i) => formatWebsearchEntry(entry, i + 1))
|
|
202
|
+
.filter((line): line is string => line !== null)
|
|
203
|
+
if (lines.length === 0) return extractContentText(result)
|
|
204
|
+
return header === null ? lines.join('\n') : `${header}\n${lines.join('\n')}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatWebsearchEntry(entry: unknown, index: number): string | null {
|
|
208
|
+
if (!isObject(entry)) return null
|
|
209
|
+
const title = asString(entry.title)
|
|
210
|
+
const url = asString(entry.url)
|
|
211
|
+
if (title === null || url === null) return null
|
|
212
|
+
return `${index}. ${title} — ${url}`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// AI-SDK style results carry a `content` array of `{type, text|data, …}` parts.
|
|
216
|
+
// We join all `text` parts with blank lines between them and replace any
|
|
217
|
+
// non-text parts with a placeholder so the user sees that something was there.
|
|
218
|
+
function extractContentText(result: ArgRecord): string | null {
|
|
219
|
+
const content = result.content
|
|
220
|
+
if (!Array.isArray(content) || content.length === 0) return null
|
|
221
|
+
const parts: string[] = []
|
|
222
|
+
for (const part of content) {
|
|
223
|
+
if (!isObject(part)) continue
|
|
224
|
+
if (part.type === 'text') {
|
|
225
|
+
const text = asString(part.text)
|
|
226
|
+
if (text !== null) parts.push(text)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (part.type === 'image') {
|
|
230
|
+
const mime = asString(part.mimeType)
|
|
231
|
+
parts.push(mime ? `[image: ${mime}]` : '[image]')
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
parts.push(`[${asString(part.type) ?? 'attachment'}]`)
|
|
235
|
+
}
|
|
236
|
+
if (parts.length === 0) return null
|
|
237
|
+
return parts.join('\n\n')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function previewArgs(name: string, args: unknown): string | null {
|
|
241
|
+
if (args === undefined || args === null) return null
|
|
242
|
+
if (typeof args === 'object' && Object.keys(args as object).length === 0) return null
|
|
243
|
+
const humanized = humanizeArgs(name, args)
|
|
244
|
+
const raw = humanized ?? toCompactString(args)
|
|
245
|
+
return truncate(raw, ARGS_PREVIEW_MAX)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function previewResult(name: string, error: boolean, result: unknown): string | null {
|
|
249
|
+
if (result === undefined || result === null) return null
|
|
250
|
+
if (typeof result === 'string') return formatPreviewBlock(result, error)
|
|
251
|
+
const humanized = humanizeResult(name, result)
|
|
252
|
+
const raw = humanized ?? toReadableString(result)
|
|
253
|
+
return raw.length === 0 ? null : formatPreviewBlock(raw, error)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatPreviewBlock(raw: string, error: boolean): string {
|
|
257
|
+
const truncated = truncate(raw, RESULT_PREVIEW_MAX)
|
|
258
|
+
const colorize = error ? colors.red : colors.gray
|
|
259
|
+
return truncated
|
|
260
|
+
.split('\n')
|
|
261
|
+
.map((line) => ` ${colorize(line)}`)
|
|
262
|
+
.join('\n')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function formatDuration(ms: number): string {
|
|
266
|
+
if (ms < 1000) return `${ms}ms`
|
|
267
|
+
const seconds = ms / 1000
|
|
268
|
+
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 2 : 1)}s`
|
|
269
|
+
const minutes = Math.floor(seconds / 60)
|
|
270
|
+
const remaining = Math.round(seconds - minutes * 60)
|
|
271
|
+
return `${minutes}m${remaining}s`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function toCompactString(value: unknown): string {
|
|
275
|
+
if (typeof value === 'string') return value
|
|
276
|
+
try {
|
|
277
|
+
return JSON.stringify(value)
|
|
278
|
+
} catch {
|
|
279
|
+
return String(value)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function toReadableString(value: unknown): string {
|
|
284
|
+
if (typeof value === 'string') return value
|
|
285
|
+
try {
|
|
286
|
+
return JSON.stringify(value, null, 2)
|
|
287
|
+
} catch {
|
|
288
|
+
return String(value)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function truncate(text: string, max: number): string {
|
|
293
|
+
if (text.length <= max) return text
|
|
294
|
+
const cut = text.slice(0, max)
|
|
295
|
+
const remaining = text.length - max
|
|
296
|
+
return `${cut}… (+${remaining} chars)`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatBytes(bytes: number): string {
|
|
300
|
+
if (bytes < 1024) return `${bytes}B`
|
|
301
|
+
const kb = bytes / 1024
|
|
302
|
+
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`
|
|
303
|
+
const mb = kb / 1024
|
|
304
|
+
return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB`
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
308
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function asString(value: unknown): string | null {
|
|
312
|
+
return typeof value === 'string' ? value : null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function asNumber(value: unknown): number | null {
|
|
316
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
317
|
+
}
|
package/src/tui/index.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text, TUI } from '@mariozechner/pi-tui'
|
|
2
|
+
|
|
3
|
+
import { createClient as createClientDefault, type Client } from './client'
|
|
4
|
+
import { formatQueuePanel, formatToolEnd, formatToolStart, formatUserPromptHistory } from './format'
|
|
5
|
+
import { colors, editorTheme, markdownTheme } from './theme'
|
|
6
|
+
|
|
7
|
+
export type ClientFactory = (url: string) => Promise<Client>
|
|
8
|
+
export type TerminalFactory = () => Terminal
|
|
9
|
+
|
|
10
|
+
export type TuiOptions = {
|
|
11
|
+
url: string
|
|
12
|
+
initialPrompt?: string
|
|
13
|
+
createClient?: ClientFactory
|
|
14
|
+
createTerminal?: TerminalFactory
|
|
15
|
+
exit?: (code: number) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createTui({
|
|
19
|
+
url,
|
|
20
|
+
initialPrompt,
|
|
21
|
+
createClient = createClientDefault,
|
|
22
|
+
createTerminal = () => new ProcessTerminal(),
|
|
23
|
+
exit = process.exit.bind(process),
|
|
24
|
+
}: TuiOptions) {
|
|
25
|
+
async function run(): Promise<void> {
|
|
26
|
+
const terminal = createTerminal()
|
|
27
|
+
const tui = new TUI(terminal)
|
|
28
|
+
|
|
29
|
+
const status = new Text(colors.dim(`connecting to ${url}...`), 0, 0)
|
|
30
|
+
tui.addChild(status)
|
|
31
|
+
tui.start()
|
|
32
|
+
tui.requestRender()
|
|
33
|
+
|
|
34
|
+
const client = await createClient(url).catch((err) => {
|
|
35
|
+
status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
|
|
36
|
+
tui.requestRender()
|
|
37
|
+
tui.stop()
|
|
38
|
+
exit(1)
|
|
39
|
+
throw err
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const sessionId = await new Promise<string>((resolve) => {
|
|
43
|
+
let off: (() => void) | undefined
|
|
44
|
+
off = client.onMessage((msg) => {
|
|
45
|
+
if (msg.type === 'connected') {
|
|
46
|
+
off?.()
|
|
47
|
+
resolve(msg.sessionId)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
status.setText(colors.dim(`session: ${sessionId}`))
|
|
52
|
+
tui.requestRender()
|
|
53
|
+
|
|
54
|
+
const editor = new Editor(tui, editorTheme, { paddingX: 0 })
|
|
55
|
+
let replyInFlight = false
|
|
56
|
+
let onReplyDone: (() => void) | null = null
|
|
57
|
+
let currentAssistant: Markdown | null = null
|
|
58
|
+
let currentAssistantText = ''
|
|
59
|
+
let queuePanel: Text | null = null
|
|
60
|
+
|
|
61
|
+
// Pi-tui's Container.addChild appends to the end of the children array.
|
|
62
|
+
// The editor must remain the LAST child at all times so it stays pinned
|
|
63
|
+
// to the bottom of the viewport, with chat history scrolling above it.
|
|
64
|
+
// The queue panel, when present, sits immediately ABOVE the editor (so
|
|
65
|
+
// the layout is [...history, queuePanel?, editor]). Any new history entry
|
|
66
|
+
// is inserted by stripping the queue panel + editor from the tail,
|
|
67
|
+
// appending the entry, then re-appending them in order.
|
|
68
|
+
const appendHistory = (component: Text | Markdown) => {
|
|
69
|
+
if (queuePanel) tui.removeChild(queuePanel)
|
|
70
|
+
tui.removeChild(editor)
|
|
71
|
+
tui.addChild(component)
|
|
72
|
+
if (queuePanel) tui.addChild(queuePanel)
|
|
73
|
+
tui.addChild(editor)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const updateQueuePanel = (pending: ReadonlyArray<{ id: string; text: string; ts: number }>) => {
|
|
77
|
+
if (pending.length === 0) {
|
|
78
|
+
if (queuePanel) {
|
|
79
|
+
tui.removeChild(queuePanel)
|
|
80
|
+
queuePanel = null
|
|
81
|
+
tui.requestRender()
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const text = formatQueuePanel(pending)
|
|
86
|
+
if (queuePanel) {
|
|
87
|
+
queuePanel.setText(text)
|
|
88
|
+
} else {
|
|
89
|
+
queuePanel = new Text(text, 0, 0)
|
|
90
|
+
tui.removeChild(editor)
|
|
91
|
+
tui.addChild(queuePanel)
|
|
92
|
+
tui.addChild(editor)
|
|
93
|
+
}
|
|
94
|
+
tui.requestRender()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Reset between text segments so a new Markdown block is created after
|
|
98
|
+
// any non-text event (tool calls). Otherwise text_delta after a tool call
|
|
99
|
+
// would append to the previous Markdown and visually push the tool lines
|
|
100
|
+
// down on every chunk.
|
|
101
|
+
const sealAssistantBlock = () => {
|
|
102
|
+
currentAssistant = null
|
|
103
|
+
currentAssistantText = ''
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const finishAssistantTurn = () => {
|
|
107
|
+
sealAssistantBlock()
|
|
108
|
+
replyInFlight = false
|
|
109
|
+
onReplyDone?.()
|
|
110
|
+
onReplyDone = null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ensureAssistantBlock = (): Markdown => {
|
|
114
|
+
if (currentAssistant) return currentAssistant
|
|
115
|
+
const md = new Markdown('', 0, 0, markdownTheme)
|
|
116
|
+
currentAssistant = md
|
|
117
|
+
currentAssistantText = ''
|
|
118
|
+
appendHistory(md)
|
|
119
|
+
return md
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
client.onMessage((msg) => {
|
|
123
|
+
switch (msg.type) {
|
|
124
|
+
case 'prompt_started': {
|
|
125
|
+
appendHistory(new Text(formatUserPromptHistory(msg.text), 0, 0))
|
|
126
|
+
tui.requestRender()
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
case 'text_delta': {
|
|
130
|
+
const block = ensureAssistantBlock()
|
|
131
|
+
currentAssistantText += msg.delta
|
|
132
|
+
block.setText(currentAssistantText)
|
|
133
|
+
tui.requestRender()
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
case 'tool_start': {
|
|
137
|
+
sealAssistantBlock()
|
|
138
|
+
appendHistory(new Text(formatToolStart(msg.name, msg.args), 0, 0))
|
|
139
|
+
tui.requestRender()
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
case 'tool_end': {
|
|
143
|
+
sealAssistantBlock()
|
|
144
|
+
appendHistory(new Text(formatToolEnd(msg.name, msg.error, msg.result, msg.durationMs), 0, 0))
|
|
145
|
+
tui.requestRender()
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
case 'done': {
|
|
149
|
+
finishAssistantTurn()
|
|
150
|
+
tui.requestRender()
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
case 'error': {
|
|
154
|
+
appendHistory(new Text(colors.red(`error: ${msg.message}`), 0, 0))
|
|
155
|
+
finishAssistantTurn()
|
|
156
|
+
tui.requestRender()
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
case 'queue_state': {
|
|
160
|
+
updateQueuePanel(msg.pending)
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const closed = new Promise<void>((resolve) => {
|
|
167
|
+
client.onClose(() => {
|
|
168
|
+
appendHistory(new Text(colors.dim('disconnected'), 0, 0))
|
|
169
|
+
tui.requestRender()
|
|
170
|
+
resolve()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
function send(text: string): Promise<void> {
|
|
175
|
+
replyInFlight = true
|
|
176
|
+
client.send({ type: 'prompt', text })
|
|
177
|
+
return new Promise<void>((resolve) => {
|
|
178
|
+
onReplyDone = resolve
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Esc aborts an in-flight reply. The Editor does not bind Esc, so a
|
|
183
|
+
// top-level input listener can intercept it without fighting the editor.
|
|
184
|
+
tui.addInputListener((data) => {
|
|
185
|
+
if (matchesKey(data, Key.escape) && replyInFlight) {
|
|
186
|
+
client.send({ type: 'abort' })
|
|
187
|
+
return { consume: true }
|
|
188
|
+
}
|
|
189
|
+
return undefined
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Ctrl+C exits cleanly. In raw mode the kernel does NOT generate SIGINT,
|
|
193
|
+
// so we must intercept the \x03 byte ourselves. The Editor would otherwise
|
|
194
|
+
// swallow it. tui.stop() restores raw-mode/cursor/echo before we exit.
|
|
195
|
+
tui.addInputListener((data) => {
|
|
196
|
+
if (matchesKey(data, Key.ctrl('c'))) {
|
|
197
|
+
tui.stop()
|
|
198
|
+
client.close()
|
|
199
|
+
exit(0)
|
|
200
|
+
return { consume: true }
|
|
201
|
+
}
|
|
202
|
+
return undefined
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
editor.onSubmit = (text) => {
|
|
206
|
+
if (text.trim().length === 0) return
|
|
207
|
+
editor.setText('')
|
|
208
|
+
editor.addToHistory(text)
|
|
209
|
+
tui.requestRender()
|
|
210
|
+
void send(text)
|
|
211
|
+
}
|
|
212
|
+
tui.addChild(editor)
|
|
213
|
+
tui.setFocus(editor)
|
|
214
|
+
tui.requestRender()
|
|
215
|
+
|
|
216
|
+
if (initialPrompt) {
|
|
217
|
+
await send(initialPrompt)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await closed
|
|
221
|
+
tui.stop()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { run }
|
|
225
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { EditorTheme, MarkdownTheme } from '@mariozechner/pi-tui'
|
|
2
|
+
|
|
3
|
+
const wrap = (code: string) => (text: string) => `\x1b[${code}m${text}\x1b[0m`
|
|
4
|
+
|
|
5
|
+
const dim = wrap('2')
|
|
6
|
+
const bold = wrap('1')
|
|
7
|
+
const red = wrap('31')
|
|
8
|
+
const green = wrap('32')
|
|
9
|
+
const yellow = wrap('33')
|
|
10
|
+
const cyan = wrap('36')
|
|
11
|
+
const gray = wrap('90')
|
|
12
|
+
|
|
13
|
+
export const colors = { dim, bold, red, green, yellow, cyan, gray }
|
|
14
|
+
|
|
15
|
+
export const editorTheme: EditorTheme = {
|
|
16
|
+
borderColor: dim,
|
|
17
|
+
selectList: {
|
|
18
|
+
selectedPrefix: cyan,
|
|
19
|
+
selectedText: bold,
|
|
20
|
+
description: dim,
|
|
21
|
+
scrollInfo: dim,
|
|
22
|
+
noMatch: dim,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const markdownTheme: MarkdownTheme = {
|
|
27
|
+
heading: bold,
|
|
28
|
+
link: cyan,
|
|
29
|
+
linkUrl: (text) => dim(`(${text})`),
|
|
30
|
+
code: yellow,
|
|
31
|
+
codeBlock: yellow,
|
|
32
|
+
codeBlockBorder: dim,
|
|
33
|
+
quote: dim,
|
|
34
|
+
quoteBorder: dim,
|
|
35
|
+
hr: dim,
|
|
36
|
+
listBullet: cyan,
|
|
37
|
+
bold,
|
|
38
|
+
italic: (text) => `\x1b[3m${text}\x1b[23m`,
|
|
39
|
+
strikethrough: (text) => `\x1b[9m${text}\x1b[29m`,
|
|
40
|
+
underline: (text) => `\x1b[4m${text}\x1b[24m`,
|
|
41
|
+
}
|