bingocode 1.0.40 → 1.1.42
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/bin/bingo-win.cjs +2 -1
- package/bin/bingocode-win.cjs +2 -1
- package/bin/claude-win.cjs +2 -1
- package/bun.lock +1716 -0
- package/package.json +14 -2
- package/src/server/config/providers.yaml +1 -1
- package/src/server/proxy/transform/anthropicToOpenaiChat.ts +23 -9
- package/adapters/README.md +0 -87
- package/adapters/common/__tests__/chat-queue.test.ts +0 -61
- package/adapters/common/__tests__/format.test.ts +0 -148
- package/adapters/common/__tests__/http-client.test.ts +0 -105
- package/adapters/common/__tests__/message-buffer.test.ts +0 -84
- package/adapters/common/__tests__/message-dedup.test.ts +0 -57
- package/adapters/common/__tests__/session-store.test.ts +0 -62
- package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
- package/adapters/common/attachment/attachment-limits.ts +0 -58
- package/adapters/common/attachment/attachment-store.ts +0 -121
- package/adapters/common/attachment/attachment-types.ts +0 -29
- package/adapters/common/attachment/image-block-watcher.ts +0 -94
- package/adapters/common/chat-queue.ts +0 -24
- package/adapters/common/config.ts +0 -96
- package/adapters/common/format.ts +0 -229
- package/adapters/common/http-client.ts +0 -107
- package/adapters/common/message-buffer.ts +0 -91
- package/adapters/common/message-dedup.ts +0 -57
- package/adapters/common/pairing.ts +0 -149
- package/adapters/common/session-store.ts +0 -60
- package/adapters/common/ws-bridge.ts +0 -282
- package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
- package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
- package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
- package/adapters/feishu/__tests__/feishu.test.ts +0 -907
- package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
- package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
- package/adapters/feishu/__tests__/media.test.ts +0 -120
- package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
- package/adapters/feishu/card-errors.ts +0 -151
- package/adapters/feishu/cardkit.ts +0 -294
- package/adapters/feishu/extract-payload.ts +0 -95
- package/adapters/feishu/flush-controller.ts +0 -149
- package/adapters/feishu/index.ts +0 -1275
- package/adapters/feishu/markdown-style.ts +0 -212
- package/adapters/feishu/media.ts +0 -176
- package/adapters/feishu/streaming-card.ts +0 -612
- package/adapters/package.json +0 -23
- package/adapters/telegram/__tests__/media.test.ts +0 -86
- package/adapters/telegram/__tests__/telegram.test.ts +0 -115
- package/adapters/telegram/index.ts +0 -754
- package/adapters/telegram/media.ts +0 -89
- package/adapters/tsconfig.json +0 -18
- package/runtime/mac_helper.py +0 -775
- package/runtime/requirements-win.txt +0 -7
- package/runtime/requirements.txt +0 -6
- package/runtime/test_helpers.py +0 -322
- package/runtime/win_helper.py +0 -723
- package/scripts/count-app-loc.ts +0 -256
- package/scripts/release.ts +0 -130
- package/start-cli.bat +0 -7
- package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
- package/stubs/color-diff-napi.ts +0 -45
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local staging directory for IM-downloaded resources.
|
|
3
|
-
*
|
|
4
|
-
* Layout: {root}/{platform}/{sessionId}/{safeName}
|
|
5
|
-
* Default root: ~/.claude/im-downloads
|
|
6
|
-
*
|
|
7
|
-
* Responsibilities:
|
|
8
|
-
* - Generate unique, safe paths from (platform, sessionId, originalName)
|
|
9
|
-
* - Atomic write (tmp → rename) so concurrent downloads never corrupt each other
|
|
10
|
-
* - GC files that haven't been touched for `retentionMs` (default 24h)
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import * as fs from 'node:fs/promises'
|
|
14
|
-
import * as fsSync from 'node:fs'
|
|
15
|
-
import type { Dirent } from 'node:fs'
|
|
16
|
-
import * as path from 'node:path'
|
|
17
|
-
import * as os from 'node:os'
|
|
18
|
-
import type { ImPlatform } from './attachment-types.js'
|
|
19
|
-
|
|
20
|
-
export interface AttachmentStoreConfig {
|
|
21
|
-
root: string
|
|
22
|
-
retentionMs: number
|
|
23
|
-
/** Grace window before a `.part` orphan (left behind by a crashed writer)
|
|
24
|
-
* is eligible for GC. Default 10 minutes. */
|
|
25
|
-
orphanGraceMs: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
29
|
-
const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000
|
|
30
|
-
|
|
31
|
-
function defaultRoot(): string {
|
|
32
|
-
return path.join(os.homedir(), '.claude', 'im-downloads')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Strip path separators / .. / control chars from a filename. */
|
|
36
|
-
function sanitizeFilename(name: string): string {
|
|
37
|
-
// eslint-disable-next-line no-control-regex
|
|
38
|
-
const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '')
|
|
39
|
-
const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_')
|
|
40
|
-
return cleaned.trim() || 'unnamed'
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export class AttachmentStore {
|
|
44
|
-
private readonly root: string
|
|
45
|
-
private readonly retentionMs: number
|
|
46
|
-
private readonly orphanGraceMs: number
|
|
47
|
-
|
|
48
|
-
constructor(config?: Partial<AttachmentStoreConfig>) {
|
|
49
|
-
this.root = config?.root ?? defaultRoot()
|
|
50
|
-
this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS
|
|
51
|
-
this.orphanGraceMs = config?.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Compute the target path. Creates parent dirs on demand.
|
|
55
|
-
* If a file with the same name already exists, prefix with a timestamp
|
|
56
|
-
* to avoid clobbering. */
|
|
57
|
-
resolvePath(platform: ImPlatform, sessionId: string, name: string): string {
|
|
58
|
-
const safeSession = sanitizeFilename(sessionId)
|
|
59
|
-
const dir = path.join(this.root, platform, safeSession)
|
|
60
|
-
fsSync.mkdirSync(dir, { recursive: true })
|
|
61
|
-
const safeName = sanitizeFilename(name)
|
|
62
|
-
const candidate = path.join(dir, safeName)
|
|
63
|
-
if (!fsSync.existsSync(candidate)) return candidate
|
|
64
|
-
const { name: base, ext } = path.parse(safeName)
|
|
65
|
-
// Collisions are rare in practice, but multiple downloads landing in the
|
|
66
|
-
// same millisecond must still produce unique paths — append a random
|
|
67
|
-
// suffix so the bare timestamp alone never clashes.
|
|
68
|
-
const rand = Math.random().toString(36).slice(2, 8)
|
|
69
|
-
return path.join(dir, `${base}-${Date.now()}-${rand}${ext}`)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Write atomically: stream to {target}.part, then rename. */
|
|
73
|
-
async write(target: string, data: Buffer): Promise<string> {
|
|
74
|
-
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
75
|
-
const tmp = `${target}.${process.pid}.${Date.now()}.part`
|
|
76
|
-
await fs.writeFile(tmp, data)
|
|
77
|
-
await fs.rename(tmp, target)
|
|
78
|
-
return target
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Remove files older than retentionMs. Returns summary. */
|
|
82
|
-
async gc(): Promise<{ removed: number; bytes: number }> {
|
|
83
|
-
let removed = 0
|
|
84
|
-
let bytes = 0
|
|
85
|
-
const now = Date.now()
|
|
86
|
-
|
|
87
|
-
const walk = async (dir: string): Promise<void> => {
|
|
88
|
-
let entries: Dirent<string>[]
|
|
89
|
-
try {
|
|
90
|
-
// Pass encoding explicitly so Dirent stays string-typed under
|
|
91
|
-
// newer @types/node where the Buffer overload becomes the default.
|
|
92
|
-
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' })
|
|
93
|
-
} catch {
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
const full = path.join(dir, entry.name)
|
|
98
|
-
if (entry.isDirectory()) {
|
|
99
|
-
await walk(full)
|
|
100
|
-
} else if (entry.isFile()) {
|
|
101
|
-
try {
|
|
102
|
-
const stat = await fs.stat(full)
|
|
103
|
-
const age = now - stat.mtimeMs
|
|
104
|
-
const isOrphanPart = entry.name.endsWith('.part')
|
|
105
|
-
const threshold = isOrphanPart ? this.orphanGraceMs : this.retentionMs
|
|
106
|
-
if (age > threshold) {
|
|
107
|
-
bytes += stat.size
|
|
108
|
-
await fs.unlink(full)
|
|
109
|
-
removed++
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
// ignore races
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
await walk(this.root).catch(() => {})
|
|
119
|
-
return { removed, bytes }
|
|
120
|
-
}
|
|
121
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared attachment types for IM adapters.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { AttachmentRef } from '../ws-bridge.js'
|
|
6
|
-
export type { AttachmentRef }
|
|
7
|
-
|
|
8
|
-
/** Platform tag — used for local staging subdir and telemetry. */
|
|
9
|
-
export type ImPlatform = 'feishu' | 'telegram'
|
|
10
|
-
|
|
11
|
-
/** Result of downloading an IM resource into the local stage dir. */
|
|
12
|
-
export interface LocalAttachment {
|
|
13
|
-
kind: 'image' | 'file'
|
|
14
|
-
name: string // original filename, or synthesized if none
|
|
15
|
-
path: string // absolute path on disk (under ~/.claude/im-downloads)
|
|
16
|
-
size: number // bytes
|
|
17
|
-
mimeType: string // detected or provided
|
|
18
|
-
buffer: Buffer // raw bytes (kept so caller can choose base64 vs path)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Pending outbound media found in Agent stream output. */
|
|
22
|
-
export interface PendingUpload {
|
|
23
|
-
id: string // fingerprint, used for dedup
|
|
24
|
-
source:
|
|
25
|
-
| { kind: 'base64'; data: string; mime: string }
|
|
26
|
-
| { kind: 'path'; path: string; mime?: string }
|
|
27
|
-
| { kind: 'url'; url: string; mime?: string }
|
|
28
|
-
alt?: string
|
|
29
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure, stateful extractor that watches a stream of assistant text for
|
|
3
|
-
* markdown image references (``) and emits PendingUpload
|
|
4
|
-
* records. Used by IM adapters to know which images to upload to IM.
|
|
5
|
-
*
|
|
6
|
-
* - Buffers input so an image marker split across multiple feed() calls
|
|
7
|
-
* still gets detected.
|
|
8
|
-
* - Dedups by fingerprint of the source so the same image is only emitted
|
|
9
|
-
* once per watcher lifetime.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { PendingUpload } from './attachment-types.js'
|
|
13
|
-
|
|
14
|
-
// Matches a complete markdown image: 
|
|
15
|
-
// `alt` may be empty; `target` stops at the first closing paren.
|
|
16
|
-
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
|
|
17
|
-
|
|
18
|
-
function fingerprint(raw: string): string {
|
|
19
|
-
let h = 5381
|
|
20
|
-
for (let i = 0; i < raw.length; i++) {
|
|
21
|
-
h = ((h << 5) + h) ^ raw.charCodeAt(i)
|
|
22
|
-
}
|
|
23
|
-
return (h >>> 0).toString(16)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function classify(target: string): PendingUpload['source'] | null {
|
|
27
|
-
if (target.startsWith('data:')) {
|
|
28
|
-
const m = /^data:([^;,]+);base64,(.+)$/.exec(target)
|
|
29
|
-
if (!m) return null
|
|
30
|
-
return { kind: 'base64', mime: m[1]!, data: m[2]! }
|
|
31
|
-
}
|
|
32
|
-
if (target.startsWith('file://')) {
|
|
33
|
-
return { kind: 'path', path: target.slice('file://'.length) }
|
|
34
|
-
}
|
|
35
|
-
if (target.startsWith('http://') || target.startsWith('https://')) {
|
|
36
|
-
return { kind: 'url', url: target }
|
|
37
|
-
}
|
|
38
|
-
if (target.startsWith('/')) {
|
|
39
|
-
return { kind: 'path', path: target }
|
|
40
|
-
}
|
|
41
|
-
return null // relative paths — skip, we can't resolve them safely
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export class ImageBlockWatcher {
|
|
45
|
-
private buffer = ''
|
|
46
|
-
private seen = new Set<string>()
|
|
47
|
-
private accumulated: PendingUpload[] = []
|
|
48
|
-
|
|
49
|
-
/** Feed a new chunk of streaming text; returns any NEW PendingUploads. */
|
|
50
|
-
feed(chunk: string): PendingUpload[] {
|
|
51
|
-
this.buffer += chunk
|
|
52
|
-
const out: PendingUpload[] = []
|
|
53
|
-
|
|
54
|
-
IMAGE_RE.lastIndex = 0
|
|
55
|
-
let lastConsumedEnd = 0
|
|
56
|
-
let m: RegExpExecArray | null
|
|
57
|
-
while ((m = IMAGE_RE.exec(this.buffer)) !== null) {
|
|
58
|
-
const [, alt, target] = m
|
|
59
|
-
const source = classify(target!)
|
|
60
|
-
if (source) {
|
|
61
|
-
const id = fingerprint(`${source.kind}:${target}`)
|
|
62
|
-
if (!this.seen.has(id)) {
|
|
63
|
-
this.seen.add(id)
|
|
64
|
-
const pending: PendingUpload = { id, source, alt: alt || undefined }
|
|
65
|
-
out.push(pending)
|
|
66
|
-
this.accumulated.push(pending)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
lastConsumedEnd = m.index + m[0].length
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Preserve tail that might contain a partially-received marker.
|
|
73
|
-
if (lastConsumedEnd > 0) {
|
|
74
|
-
this.buffer = this.buffer.slice(lastConsumedEnd)
|
|
75
|
-
}
|
|
76
|
-
if (this.buffer.length > 4096) {
|
|
77
|
-
this.buffer = this.buffer.slice(-2048)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return out
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Return everything seen so far (for end-of-stream reconciliation). */
|
|
84
|
-
drain(): PendingUpload[] {
|
|
85
|
-
return [...this.accumulated]
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Reset watcher state (use at /clear or new session). */
|
|
89
|
-
reset(): void {
|
|
90
|
-
this.buffer = ''
|
|
91
|
-
this.seen.clear()
|
|
92
|
-
this.accumulated = []
|
|
93
|
-
}
|
|
94
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 会话串行队列
|
|
3
|
-
*
|
|
4
|
-
* 同一 chatId 的消息串行处理,防并发冲突。
|
|
5
|
-
* 不同 chatId 之间互不影响。
|
|
6
|
-
* 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const queues = new Map<string, Promise<void>>()
|
|
10
|
-
|
|
11
|
-
export async function enqueue(chatId: string, fn: () => Promise<void>): Promise<void> {
|
|
12
|
-
const prev = queues.get(chatId) ?? Promise.resolve()
|
|
13
|
-
const next = prev.then(fn, () => fn()).catch((err) => {
|
|
14
|
-
console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err)
|
|
15
|
-
})
|
|
16
|
-
queues.set(chatId, next)
|
|
17
|
-
// Clean up after completion to avoid memory leak for one-off chats
|
|
18
|
-
next.finally(() => {
|
|
19
|
-
if (queues.get(chatId) === next) {
|
|
20
|
-
queues.delete(chatId)
|
|
21
|
-
}
|
|
22
|
-
})
|
|
23
|
-
return next
|
|
24
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Adapter 配置加载
|
|
3
|
-
*
|
|
4
|
-
* 优先级:环境变量 > ~/.claude/adapters.json > 默认值
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import * as fs from 'node:fs'
|
|
8
|
-
import * as os from 'node:os'
|
|
9
|
-
import * as path from 'node:path'
|
|
10
|
-
|
|
11
|
-
export type PairedUser = {
|
|
12
|
-
userId: string | number
|
|
13
|
-
displayName: string
|
|
14
|
-
pairedAt: number
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type PairingState = {
|
|
18
|
-
code: string | null
|
|
19
|
-
expiresAt: number | null
|
|
20
|
-
createdAt: number | null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type TelegramConfig = {
|
|
24
|
-
botToken: string
|
|
25
|
-
allowedUsers: number[]
|
|
26
|
-
pairedUsers: PairedUser[]
|
|
27
|
-
defaultWorkDir: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type FeishuConfig = {
|
|
31
|
-
appId: string
|
|
32
|
-
appSecret: string
|
|
33
|
-
encryptKey: string
|
|
34
|
-
verificationToken: string
|
|
35
|
-
allowedUsers: string[]
|
|
36
|
-
pairedUsers: PairedUser[]
|
|
37
|
-
defaultWorkDir: string
|
|
38
|
-
streamingCard: boolean
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type AdapterConfig = {
|
|
42
|
-
serverUrl: string
|
|
43
|
-
defaultProjectDir: string
|
|
44
|
-
pairing: PairingState
|
|
45
|
-
telegram: TelegramConfig
|
|
46
|
-
feishu: FeishuConfig
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function getConfigPath(): string {
|
|
50
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
51
|
-
return path.join(configDir, 'adapters.json')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function loadFile(): Record<string, any> {
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
|
57
|
-
} catch (err: any) {
|
|
58
|
-
if (err?.code !== 'ENOENT') {
|
|
59
|
-
console.warn(`[Config] Failed to parse ${getConfigPath()}, using defaults`)
|
|
60
|
-
}
|
|
61
|
-
return {}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function loadConfig(): AdapterConfig {
|
|
66
|
-
const file = loadFile()
|
|
67
|
-
const tg = file.telegram ?? {}
|
|
68
|
-
const fs_ = file.feishu ?? {}
|
|
69
|
-
const pairing = file.pairing ?? {}
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
|
|
73
|
-
defaultProjectDir: file.defaultProjectDir || '',
|
|
74
|
-
pairing: {
|
|
75
|
-
code: pairing.code ?? null,
|
|
76
|
-
expiresAt: pairing.expiresAt ?? null,
|
|
77
|
-
createdAt: pairing.createdAt ?? null,
|
|
78
|
-
},
|
|
79
|
-
telegram: {
|
|
80
|
-
botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
|
|
81
|
-
allowedUsers: tg.allowedUsers ?? [],
|
|
82
|
-
pairedUsers: tg.pairedUsers ?? [],
|
|
83
|
-
defaultWorkDir: tg.defaultWorkDir || process.cwd(),
|
|
84
|
-
},
|
|
85
|
-
feishu: {
|
|
86
|
-
appId: process.env.FEISHU_APP_ID || fs_.appId || '',
|
|
87
|
-
appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
|
|
88
|
-
encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
|
|
89
|
-
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
|
|
90
|
-
allowedUsers: fs_.allowedUsers ?? [],
|
|
91
|
-
pairedUsers: fs_.pairedUsers ?? [],
|
|
92
|
-
defaultWorkDir: fs_.defaultWorkDir || process.cwd(),
|
|
93
|
-
streamingCard: fs_.streamingCard ?? false,
|
|
94
|
-
},
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 消息格式化工具
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
type AdapterChatState =
|
|
6
|
-
| 'idle'
|
|
7
|
-
| 'thinking'
|
|
8
|
-
| 'streaming'
|
|
9
|
-
| 'tool_executing'
|
|
10
|
-
| 'permission_pending'
|
|
11
|
-
|
|
12
|
-
type ImStatusSummary = {
|
|
13
|
-
sessionId?: string
|
|
14
|
-
projectName?: string | null
|
|
15
|
-
branch?: string | null
|
|
16
|
-
model?: string | null
|
|
17
|
-
state?: AdapterChatState | null
|
|
18
|
-
verb?: string | null
|
|
19
|
-
pendingPermissionCount?: number
|
|
20
|
-
taskCounts?: {
|
|
21
|
-
total: number
|
|
22
|
-
pending: number
|
|
23
|
-
inProgress: number
|
|
24
|
-
completed: number
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const IM_HELP_LINES = [
|
|
29
|
-
'/new [项目] — 新建会话或切换项目',
|
|
30
|
-
'/projects — 查看最近项目',
|
|
31
|
-
'/status — 查看当前会话状态',
|
|
32
|
-
'/clear — 清空当前会话上下文',
|
|
33
|
-
'/stop — 停止当前生成',
|
|
34
|
-
'/help — 显示这份帮助',
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
/** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
|
|
38
|
-
export function splitMessage(text: string, limit: number): string[] {
|
|
39
|
-
if (text.length <= limit) return [text]
|
|
40
|
-
|
|
41
|
-
const chunks: string[] = []
|
|
42
|
-
let remaining = text
|
|
43
|
-
|
|
44
|
-
while (remaining.length > 0) {
|
|
45
|
-
if (remaining.length <= limit) {
|
|
46
|
-
chunks.push(remaining)
|
|
47
|
-
break
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
let splitAt = remaining.lastIndexOf('\n\n', limit)
|
|
51
|
-
if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit)
|
|
52
|
-
if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit)
|
|
53
|
-
if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit)
|
|
54
|
-
if (splitAt <= 0) splitAt = limit
|
|
55
|
-
|
|
56
|
-
// Include the delimiter for paragraph/sentence breaks
|
|
57
|
-
if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1
|
|
58
|
-
|
|
59
|
-
chunks.push(remaining.slice(0, splitAt).trimEnd())
|
|
60
|
-
remaining = remaining.slice(splitAt).trimStart()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return chunks
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Format tool use info for display in IM. */
|
|
67
|
-
export function formatToolUse(toolName: string, input: unknown): string {
|
|
68
|
-
const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
|
|
69
|
-
const summary = formatToolSummary(toolName, inp)
|
|
70
|
-
if (summary) return `🔧 ${toolName} ${summary}`
|
|
71
|
-
const preview = truncateInput(input, 200)
|
|
72
|
-
return `🔧 ${toolName}\n${preview}`
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Generate a concise human-readable summary for common tools. */
|
|
76
|
-
function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
|
|
77
|
-
switch (tool) {
|
|
78
|
-
case 'Bash': {
|
|
79
|
-
const desc = inp.description as string | undefined
|
|
80
|
-
const cmd = inp.command as string | undefined
|
|
81
|
-
if (desc) return desc
|
|
82
|
-
if (cmd) return truncate(cmd, 120)
|
|
83
|
-
return null
|
|
84
|
-
}
|
|
85
|
-
case 'Read': {
|
|
86
|
-
const fp = inp.file_path as string | undefined
|
|
87
|
-
if (fp) return shortPath(fp)
|
|
88
|
-
return null
|
|
89
|
-
}
|
|
90
|
-
case 'Edit': {
|
|
91
|
-
const fp = inp.file_path as string | undefined
|
|
92
|
-
if (fp) return shortPath(fp)
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
|
-
case 'Write': {
|
|
96
|
-
const fp = inp.file_path as string | undefined
|
|
97
|
-
if (fp) return shortPath(fp)
|
|
98
|
-
return null
|
|
99
|
-
}
|
|
100
|
-
case 'Grep': {
|
|
101
|
-
const pat = inp.pattern as string | undefined
|
|
102
|
-
const p = inp.path as string | undefined
|
|
103
|
-
if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
case 'Glob': {
|
|
107
|
-
const pat = inp.pattern as string | undefined
|
|
108
|
-
return pat ? `"${pat}"` : null
|
|
109
|
-
}
|
|
110
|
-
case 'Skill': {
|
|
111
|
-
const skill = inp.skill as string | undefined
|
|
112
|
-
return skill || null
|
|
113
|
-
}
|
|
114
|
-
case 'Agent': {
|
|
115
|
-
const desc = inp.description as string | undefined
|
|
116
|
-
return desc || null
|
|
117
|
-
}
|
|
118
|
-
case 'WebFetch': {
|
|
119
|
-
const url = inp.url as string | undefined
|
|
120
|
-
return url ? truncate(url, 120) : null
|
|
121
|
-
}
|
|
122
|
-
case 'WebSearch': {
|
|
123
|
-
const q = inp.query as string | undefined
|
|
124
|
-
return q ? `"${truncate(q, 80)}"` : null
|
|
125
|
-
}
|
|
126
|
-
default:
|
|
127
|
-
return null
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function shortPath(fp: string): string {
|
|
132
|
-
const parts = fp.split('/')
|
|
133
|
-
return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function truncate(s: string, max: number): string {
|
|
137
|
-
return s.length > max ? s.slice(0, max) + '…' : s
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Format a permission request for display in IM. */
|
|
141
|
-
export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
|
|
142
|
-
const preview = truncateInput(input, 300)
|
|
143
|
-
return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}`
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Truncate tool input to a preview string. */
|
|
147
|
-
export function truncateInput(input: unknown, maxLen: number): string {
|
|
148
|
-
try {
|
|
149
|
-
const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
|
150
|
-
return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
|
|
151
|
-
} catch {
|
|
152
|
-
return '(unserializable)'
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Escape special characters for Telegram MarkdownV2. */
|
|
157
|
-
export function escapeMarkdownV2(text: string): string {
|
|
158
|
-
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function formatImHelp(): string {
|
|
162
|
-
return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function formatImStatus(summary: ImStatusSummary | null): string {
|
|
166
|
-
if (!summary?.sessionId) {
|
|
167
|
-
return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const lines = ['当前会话状态:']
|
|
171
|
-
|
|
172
|
-
if (summary.projectName) {
|
|
173
|
-
lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
|
|
174
|
-
} else if (summary.branch) {
|
|
175
|
-
lines.push(`分支: ${summary.branch}`)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
|
|
179
|
-
|
|
180
|
-
if (summary.model) {
|
|
181
|
-
lines.push(`模型: ${summary.model}`)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
|
|
185
|
-
|
|
186
|
-
const pendingPermissionCount = summary.pendingPermissionCount ?? 0
|
|
187
|
-
if (pendingPermissionCount > 0) {
|
|
188
|
-
lines.push(`审批: ${pendingPermissionCount} 个待确认`)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const taskCounts = summary.taskCounts
|
|
192
|
-
if (taskCounts && taskCounts.total > 0) {
|
|
193
|
-
const taskParts = [`总计 ${taskCounts.total}`]
|
|
194
|
-
if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
|
|
195
|
-
if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
|
|
196
|
-
if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
|
|
197
|
-
lines.push(`任务: ${taskParts.join(' · ')}`)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return lines.join('\n')
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function formatAdapterChatState(
|
|
204
|
-
state: AdapterChatState | null | undefined,
|
|
205
|
-
verb: string | null | undefined,
|
|
206
|
-
): string {
|
|
207
|
-
const label = (() => {
|
|
208
|
-
switch (state) {
|
|
209
|
-
case 'thinking':
|
|
210
|
-
return '思考中'
|
|
211
|
-
case 'streaming':
|
|
212
|
-
return '生成中'
|
|
213
|
-
case 'tool_executing':
|
|
214
|
-
return '执行工具中'
|
|
215
|
-
case 'permission_pending':
|
|
216
|
-
return '等待权限确认'
|
|
217
|
-
case 'idle':
|
|
218
|
-
default:
|
|
219
|
-
return '空闲'
|
|
220
|
-
}
|
|
221
|
-
})()
|
|
222
|
-
|
|
223
|
-
if (!verb || verb === 'Thinking') return label
|
|
224
|
-
return `${label} (${verb})`
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function shortSessionId(sessionId: string): string {
|
|
228
|
-
return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId
|
|
229
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
export type RecentProject = {
|
|
2
|
-
projectPath: string
|
|
3
|
-
realPath: string
|
|
4
|
-
projectName: string
|
|
5
|
-
isGit: boolean
|
|
6
|
-
repoName: string | null
|
|
7
|
-
branch: string | null
|
|
8
|
-
modifiedAt: string
|
|
9
|
-
sessionCount: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export type GitInfo = {
|
|
13
|
-
branch: string | null
|
|
14
|
-
repoName: string | null
|
|
15
|
-
workDir: string
|
|
16
|
-
changedFiles: number
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type SessionTask = {
|
|
20
|
-
id: string
|
|
21
|
-
subject: string
|
|
22
|
-
status: 'pending' | 'in_progress' | 'completed'
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export class AdapterHttpClient {
|
|
26
|
-
readonly httpBaseUrl: string
|
|
27
|
-
|
|
28
|
-
constructor(wsUrl: string) {
|
|
29
|
-
this.httpBaseUrl = wsUrl
|
|
30
|
-
.replace(/^ws:/, 'http:')
|
|
31
|
-
.replace(/^wss:/, 'https:')
|
|
32
|
-
.replace(/\/$/, '')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async createSession(workDir: string): Promise<string> {
|
|
36
|
-
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
|
37
|
-
method: 'POST',
|
|
38
|
-
headers: { 'Content-Type': 'application/json' },
|
|
39
|
-
body: JSON.stringify({ workDir }),
|
|
40
|
-
})
|
|
41
|
-
if (!res.ok) {
|
|
42
|
-
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
43
|
-
throw new Error(`Failed to create session: ${(err as any).message}`)
|
|
44
|
-
}
|
|
45
|
-
const data = (await res.json()) as { sessionId: string }
|
|
46
|
-
return data.sessionId
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async listRecentProjects(): Promise<RecentProject[]> {
|
|
50
|
-
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`)
|
|
51
|
-
if (!res.ok) {
|
|
52
|
-
throw new Error(`Failed to list projects: ${res.statusText}`)
|
|
53
|
-
}
|
|
54
|
-
const data = (await res.json()) as { projects: RecentProject[] }
|
|
55
|
-
return data.projects
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Match a project by index (1-based) or fuzzy name from recent projects.
|
|
60
|
-
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
|
|
61
|
-
*/
|
|
62
|
-
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
|
|
63
|
-
const projects = await this.listRecentProjects()
|
|
64
|
-
|
|
65
|
-
// Try as 1-based index
|
|
66
|
-
const num = parseInt(query, 10)
|
|
67
|
-
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
|
|
68
|
-
return { project: projects[num - 1] }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const q = query.toLowerCase()
|
|
72
|
-
|
|
73
|
-
// Exact project name match
|
|
74
|
-
const exact = projects.find(p => p.projectName.toLowerCase() === q)
|
|
75
|
-
if (exact) return { project: exact }
|
|
76
|
-
|
|
77
|
-
// Fuzzy: name or path contains query
|
|
78
|
-
const matches = projects.filter(p =>
|
|
79
|
-
p.projectName.toLowerCase().includes(q) ||
|
|
80
|
-
p.realPath.toLowerCase().includes(q)
|
|
81
|
-
)
|
|
82
|
-
if (matches.length === 1) return { project: matches[0] }
|
|
83
|
-
if (matches.length > 1) return { ambiguous: matches }
|
|
84
|
-
|
|
85
|
-
return {}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
|
89
|
-
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`)
|
|
90
|
-
if (!res.ok) {
|
|
91
|
-
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
92
|
-
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
|
93
|
-
}
|
|
94
|
-
return (await res.json()) as GitInfo
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
|
98
|
-
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`)
|
|
99
|
-
if (!res.ok) {
|
|
100
|
-
if (res.status === 404) return []
|
|
101
|
-
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
102
|
-
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
|
103
|
-
}
|
|
104
|
-
const data = (await res.json()) as { tasks?: SessionTask[] }
|
|
105
|
-
return Array.isArray(data.tasks) ? data.tasks : []
|
|
106
|
-
}
|
|
107
|
-
}
|