Merge remote-tracking branch 'origin/main' into feat/migrate-review-agents
This commit is contained in:
commit
a5235ab03c
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
13
package.json
13
package.json
|
|
@ -44,6 +44,19 @@
|
|||
"build:vite": "vite build && bun run scripts/post-build.ts",
|
||||
"build:vite:only": "vite build",
|
||||
"build:bun": "bun run build.ts",
|
||||
"build:binary:linux": "bun run build && bun run compile:linux",
|
||||
"build:binary:linux-baseline": "bun run build && bun run compile:linux-baseline",
|
||||
"build:binary:linux-musl": "bun run build && bun run compile:linux-musl",
|
||||
"build:binary:linux-musl-baseline": "bun run build && bun run compile:linux-musl-baseline",
|
||||
"build:binary:win": "bun run build && bun run compile:win",
|
||||
"build:binary:win-baseline": "bun run build && bun run compile:win-baseline",
|
||||
"build:binary:all": "bun run build && bun run compile:linux && bun run compile:linux-baseline && bun run compile:linux-musl && bun run compile:linux-musl-baseline && bun run compile:win && bun run compile:win-baseline",
|
||||
"compile:linux": "bun build dist/cli.js --compile --target=bun-linux-x64 --outfile dist/csc-linux-x64",
|
||||
"compile:linux-baseline": "bun build dist/cli.js --compile --target=bun-linux-x64-baseline --outfile dist/csc-linux-x64-baseline",
|
||||
"compile:linux-musl": "bun build dist/cli.js --compile --target=bun-linux-x64-musl --outfile dist/csc-linux-x64-musl",
|
||||
"compile:linux-musl-baseline": "bun build dist/cli.js --compile --target=bun-linux-x64-musl-baseline --outfile dist/csc-linux-x64-musl-baseline",
|
||||
"compile:win": "bun build dist/cli.js --compile --target=bun-windows-x64 --outfile dist/csc-windows-x64.exe",
|
||||
"compile:win-baseline": "bun build dist/cli.js --compile --target=bun-windows-x64-baseline --outfile dist/csc-windows-x64-baseline.exe",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||
"prepublishOnly": "bun run build",
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
|||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
|
||||
// 'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务(默认关闭,开启后强制所有 agent 异步运行,run_in_background 参数失效)
|
||||
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||
'KAIROS', // Kairos 定时任务系统核心
|
||||
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import path from 'path'
|
|||
import { fileURLToPath } from 'url'
|
||||
import { spawnSync } from 'child_process'
|
||||
import matter from 'gray-matter'
|
||||
import { mkdir } from 'fs/promises'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
|
@ -260,6 +261,7 @@ export function getSkillMetadata(skillName: string, locale: string): { name: str
|
|||
}
|
||||
`
|
||||
|
||||
await mkdir(path.dirname(builtinSkillsFile), { recursive: true })
|
||||
await fs.writeFile(builtinSkillsFile, content, 'utf-8')
|
||||
console.log(`\n✓ Generated ${builtinSkillsFile}`)
|
||||
}
|
||||
|
|
@ -280,6 +282,7 @@ export const PRIMARY_REVIEW_AGENT = ''
|
|||
export const SUB_REVIEW_AGENT = ''
|
||||
`
|
||||
|
||||
await mkdir(path.dirname(builtinAgentsFile), { recursive: true })
|
||||
await fs.writeFile(builtinAgentsFile, content, 'utf-8')
|
||||
console.log(`✓ Generated ${builtinAgentsFile}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import type { MCPServerConnection } from './services/mcp/types.js'
|
|||
import type { AppState } from './state/AppState.js'
|
||||
import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { isBuiltInAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
import type { CompactMetadata, Message, SystemCompactBoundaryMessage } from './types/message.js'
|
||||
|
|
@ -77,7 +78,7 @@ import {
|
|||
flushSessionStorage,
|
||||
recordTranscript,
|
||||
} from './utils/sessionStorage.js'
|
||||
import { asSystemPrompt } from './utils/systemPromptType.js'
|
||||
import { buildEffectiveSystemPrompt } from './utils/systemPrompt.js'
|
||||
import { resolveThemeSetting } from './utils/systemTheme.js'
|
||||
import {
|
||||
shouldEnableThinkingByDefault,
|
||||
|
|
@ -139,6 +140,7 @@ export type QueryEngineConfig = {
|
|||
commands: Command[]
|
||||
mcpClients: MCPServerConnection[]
|
||||
agents: AgentDefinition[]
|
||||
allowedAgentTypes?: string[]
|
||||
canUseTool: CanUseToolFn
|
||||
getAppState: () => AppState
|
||||
setAppState: (f: (prev: AppState) => AppState) => void
|
||||
|
|
@ -146,6 +148,7 @@ export type QueryEngineConfig = {
|
|||
readFileCache: FileStateCache
|
||||
customSystemPrompt?: string
|
||||
appendSystemPrompt?: string
|
||||
mainThreadAgentDefinition?: AgentDefinition
|
||||
userSpecifiedModel?: string
|
||||
fallbackModel?: string
|
||||
thinkingConfig?: ThinkingConfig
|
||||
|
|
@ -229,6 +232,7 @@ export class QueryEngine {
|
|||
canUseTool,
|
||||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
mainThreadAgentDefinition,
|
||||
userSpecifiedModel,
|
||||
fallbackModel,
|
||||
jsonSchema,
|
||||
|
|
@ -237,6 +241,7 @@ export class QueryEngine {
|
|||
replayUserMessages = false,
|
||||
includePartialMessages = false,
|
||||
agents = [],
|
||||
allowedAgentTypes,
|
||||
setSDKStatus,
|
||||
orphanedPermission,
|
||||
} = this.config
|
||||
|
|
@ -292,6 +297,13 @@ export class QueryEngine {
|
|||
// Narrow once so TS tracks the type through the conditionals below.
|
||||
const customPrompt =
|
||||
typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined
|
||||
// For built-in agents, don't pass customSystemPrompt to fetchSystemPromptParts
|
||||
// (it's only used for cache key / context building there). The actual system
|
||||
// prompt is assembled below via buildEffectiveSystemPrompt.
|
||||
const customPromptForFetch =
|
||||
mainThreadAgentDefinition && isBuiltInAgent(mainThreadAgentDefinition)
|
||||
? undefined
|
||||
: customPrompt
|
||||
const {
|
||||
defaultSystemPrompt,
|
||||
userContext: baseUserContext,
|
||||
|
|
@ -303,7 +315,7 @@ export class QueryEngine {
|
|||
initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
||||
),
|
||||
mcpClients,
|
||||
customSystemPrompt: customPrompt,
|
||||
customSystemPrompt: customPromptForFetch,
|
||||
})
|
||||
headlessProfilerCheckpoint('after_getSystemPrompt')
|
||||
const userContext = {
|
||||
|
|
@ -325,11 +337,18 @@ export class QueryEngine {
|
|||
? await loadMemoryPrompt()
|
||||
: null
|
||||
|
||||
const systemPrompt = asSystemPrompt([
|
||||
...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),
|
||||
const combinedAppendSystemPrompt = [
|
||||
...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
|
||||
...(appendSystemPrompt ? [appendSystemPrompt] : []),
|
||||
])
|
||||
].join('\n') || undefined
|
||||
|
||||
const systemPrompt = buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition,
|
||||
toolUseContext: { options: { customSystemPrompt: customPrompt } as ToolUseContext['options'] },
|
||||
customSystemPrompt: customPrompt,
|
||||
defaultSystemPrompt,
|
||||
appendSystemPrompt: combinedAppendSystemPrompt,
|
||||
})
|
||||
|
||||
// Register function hook for structured output enforcement
|
||||
const hasStructuredOutputTool = tools.some(t =>
|
||||
|
|
@ -366,7 +385,7 @@ export class QueryEngine {
|
|||
isNonInteractiveSession: true,
|
||||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
agentDefinitions: { activeAgents: agents, allAgents: [] },
|
||||
agentDefinitions: { activeAgents: agents, allAgents: [], ...(allowedAgentTypes ? { allowedAgentTypes } : {}) },
|
||||
theme: resolveThemeSetting(getGlobalConfig().theme),
|
||||
maxBudgetUsd,
|
||||
},
|
||||
|
|
@ -516,7 +535,7 @@ export class QueryEngine {
|
|||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
theme: resolveThemeSetting(getGlobalConfig().theme),
|
||||
agentDefinitions: { activeAgents: agents, allAgents: [] },
|
||||
agentDefinitions: { activeAgents: agents, allAgents: [], ...(allowedAgentTypes ? { allowedAgentTypes } : {}) },
|
||||
maxBudgetUsd,
|
||||
},
|
||||
getAppState,
|
||||
|
|
@ -1245,6 +1264,7 @@ export async function* ask({
|
|||
setReadFileCache,
|
||||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
mainThreadAgentDefinition,
|
||||
userSpecifiedModel,
|
||||
fallbackModel,
|
||||
jsonSchema,
|
||||
|
|
@ -1255,6 +1275,7 @@ export async function* ask({
|
|||
includePartialMessages = false,
|
||||
handleElicitation,
|
||||
agents = [],
|
||||
allowedAgentTypes,
|
||||
setSDKStatus,
|
||||
orphanedPermission,
|
||||
}: {
|
||||
|
|
@ -1274,6 +1295,7 @@ export async function* ask({
|
|||
mutableMessages?: Message[]
|
||||
customSystemPrompt?: string
|
||||
appendSystemPrompt?: string
|
||||
mainThreadAgentDefinition?: AgentDefinition
|
||||
userSpecifiedModel?: string
|
||||
fallbackModel?: string
|
||||
jsonSchema?: Record<string, unknown>
|
||||
|
|
@ -1286,6 +1308,7 @@ export async function* ask({
|
|||
includePartialMessages?: boolean
|
||||
handleElicitation?: ToolUseContext['handleElicitation']
|
||||
agents?: AgentDefinition[]
|
||||
allowedAgentTypes?: string[]
|
||||
setSDKStatus?: (status: SDKStatus) => void
|
||||
orphanedPermission?: OrphanedPermission
|
||||
}): AsyncGenerator<SDKMessage, void, unknown> {
|
||||
|
|
@ -1295,6 +1318,7 @@ export async function* ask({
|
|||
commands,
|
||||
mcpClients,
|
||||
agents: agents ?? [],
|
||||
allowedAgentTypes,
|
||||
canUseTool,
|
||||
getAppState,
|
||||
setAppState,
|
||||
|
|
@ -1302,6 +1326,7 @@ export async function* ask({
|
|||
readFileCache: cloneFileStateCache(getReadFileCache()),
|
||||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
mainThreadAgentDefinition,
|
||||
userSpecifiedModel,
|
||||
fallbackModel,
|
||||
thinkingConfig,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
isBuiltInAgent,
|
||||
parseAgentsFromJson,
|
||||
} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { resolveAgentTools } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
|
||||
import type { Message, NormalizedUserMessage } from 'src/types/message.js'
|
||||
import type { QueuedCommand } from 'src/types/textInputTypes.js'
|
||||
import {
|
||||
|
|
@ -2027,7 +2028,15 @@ function runHeadlessStreaming(
|
|||
reregisterChannelHandlerAfterReconnect(client)
|
||||
}
|
||||
|
||||
const allTools = buildAllTools(appState)
|
||||
const rawTools = buildAllTools(appState)
|
||||
const mainThreadAgentDef = currentAgents.find(
|
||||
a => a.agentType === getMainThreadAgentType(),
|
||||
)
|
||||
const resolvedAgentResult = mainThreadAgentDef
|
||||
? resolveAgentTools(mainThreadAgentDef, rawTools, false, true)
|
||||
: null
|
||||
const allTools = resolvedAgentResult ? resolvedAgentResult.resolvedTools : rawTools
|
||||
const mainThreadAllowedAgentTypes = resolvedAgentResult?.allowedAgentTypes
|
||||
|
||||
for (const uuid of batchUuids) {
|
||||
notifyCommandLifecycle(uuid, 'started')
|
||||
|
|
@ -2217,6 +2226,9 @@ function runHeadlessStreaming(
|
|||
},
|
||||
customSystemPrompt: options.systemPrompt,
|
||||
appendSystemPrompt: options.appendSystemPrompt,
|
||||
mainThreadAgentDefinition: currentAgents.find(
|
||||
a => a.agentType === getMainThreadAgentType(),
|
||||
),
|
||||
getAppState,
|
||||
setAppState,
|
||||
abortController,
|
||||
|
|
@ -2235,6 +2247,7 @@ function runHeadlessStreaming(
|
|||
: undefined,
|
||||
),
|
||||
agents: currentAgents,
|
||||
allowedAgentTypes: mainThreadAllowedAgentTypes,
|
||||
orphanedPermission: cmd.orphanedPermission,
|
||||
setSDKStatus: status => {
|
||||
output.enqueue({
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ import exit from './commands/exit/index.js'
|
|||
import exportCommand from './commands/export/index.js'
|
||||
import model from './commands/model/index.js'
|
||||
import tag from './commands/tag/index.js'
|
||||
import favorite from './commands/favorite/index.js'
|
||||
import outputStyle from './commands/output-style/index.js'
|
||||
import remoteEnv from './commands/remote-env/index.js'
|
||||
import upgrade from './commands/upgrade/index.js'
|
||||
|
|
@ -347,6 +348,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||
statusline,
|
||||
stickers,
|
||||
tag,
|
||||
favorite,
|
||||
theme,
|
||||
feedback,
|
||||
review,
|
||||
|
|
|
|||
178
src/commands/favorite/favorite.ts
Normal file
178
src/commands/favorite/favorite.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import type { LocalCommandCall, LocalCommandResult } from '../../types/command.js'
|
||||
import {
|
||||
listFavoriteItems,
|
||||
downloadFavoriteItem,
|
||||
loadFavoriteItem,
|
||||
unloadFavoriteItem,
|
||||
uninstallFavoriteItem,
|
||||
viewFavoriteItem,
|
||||
type FavoriteItemType,
|
||||
} from '../../costrict/favorite/favorite.js'
|
||||
|
||||
function pad(value: string, width: number) {
|
||||
return value.length >= width ? value : value + ' '.repeat(width - value.length)
|
||||
}
|
||||
|
||||
function parseFavoriteArgs(args: string) {
|
||||
const tokens = args.trim().split(/\s+/)
|
||||
const [command = '', idOrFlag, ...rest] = tokens
|
||||
let id = ''
|
||||
let format: 'table' | 'json' = 'table'
|
||||
let type: FavoriteItemType | undefined
|
||||
|
||||
const remaining = [idOrFlag, ...rest].filter((item): item is string => Boolean(item))
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const token = remaining[i]
|
||||
if (token === '--format' && remaining[i + 1]) {
|
||||
const value = remaining[++i]
|
||||
if (value === 'table' || value === 'json') format = value
|
||||
continue
|
||||
}
|
||||
if (token.startsWith('--format=')) {
|
||||
const value = token.slice('--format='.length)
|
||||
if (value === 'table' || value === 'json') format = value
|
||||
continue
|
||||
}
|
||||
if (token === '--type' && remaining[i + 1]) {
|
||||
const value = remaining[++i]
|
||||
if (value === 'skill' || value === 'agent' || value === 'command' || value === 'mcp') type = value as FavoriteItemType
|
||||
continue
|
||||
}
|
||||
if (token.startsWith('--type=')) {
|
||||
const value = token.slice('--type='.length)
|
||||
if (value === 'skill' || value === 'agent' || value === 'command' || value === 'mcp') type = value as FavoriteItemType
|
||||
continue
|
||||
}
|
||||
if (!id && !token.startsWith('-')) {
|
||||
id = token
|
||||
}
|
||||
}
|
||||
|
||||
return { command, id, format, type }
|
||||
}
|
||||
|
||||
async function printFavoriteList(format: 'table' | 'json', type?: FavoriteItemType): Promise<string> {
|
||||
const items = await listFavoriteItems(type)
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(items, null, 2)
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return 'No cloud favorites found'
|
||||
}
|
||||
|
||||
const statusWidth = Math.max('Status'.length, ...items.map((item) => item.status.length))
|
||||
const typeWidth = Math.max('Type'.length, ...items.map((item) => item.itemType.length))
|
||||
const slugWidth = Math.max('Slug'.length, ...items.map((item) => item.slug.length))
|
||||
const nameWidth = Math.max('Name'.length, ...items.map((item) => item.name.length))
|
||||
|
||||
const lines: string[] = [
|
||||
`${pad('Status', statusWidth)} ${pad('Type', typeWidth)} ${pad('Slug', slugWidth)} ${pad('Name', nameWidth)} Description`,
|
||||
]
|
||||
for (const item of items) {
|
||||
lines.push(
|
||||
`${pad(item.status, statusWidth)} ${pad(item.itemType, typeWidth)} ${pad(item.slug, slugWidth)} ${pad(item.name, nameWidth)} ${item.description}`,
|
||||
)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function printFavoriteView(id: string, format: 'table' | 'json'): Promise<string> {
|
||||
const item = await viewFavoriteItem(id)
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(item, null, 2)
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Name: ${item.name}`,
|
||||
`Slug: ${item.slug}`,
|
||||
`ID: ${item.id}`,
|
||||
`Type: ${item.itemType}`,
|
||||
`Status: ${item.status}`,
|
||||
`Favorites: ${item.favoriteCount ?? 0}`,
|
||||
]
|
||||
if (item.version) lines.push(`Version: ${item.version}`)
|
||||
if (item.localPath) lines.push(`Local path: ${item.localPath}`)
|
||||
lines.push('', item.description || '(no description)')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function runFavoriteAction(action: 'download' | 'load' | 'unload' | 'uninstall', id: string): Promise<string> {
|
||||
switch (action) {
|
||||
case 'download': {
|
||||
const item = await downloadFavoriteItem(id)
|
||||
return `downloaded ${item.slug}`
|
||||
}
|
||||
case 'load': {
|
||||
const item = await loadFavoriteItem(id)
|
||||
return `loaded ${item.slug} as active`
|
||||
}
|
||||
case 'unload': {
|
||||
const item = await unloadFavoriteItem(id)
|
||||
return `unloaded ${item.slug}`
|
||||
}
|
||||
case 'uninstall': {
|
||||
const item = await uninstallFavoriteItem(id)
|
||||
return `uninstalled ${item.slug}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HELP_TEXT = `Usage: /favorite <command> [args...]
|
||||
|
||||
Manage CoStrict cloud favorite items (skills, agents, commands, MCPs).
|
||||
|
||||
Commands:
|
||||
list List cloud favorite items
|
||||
view Show favorite item details
|
||||
download Download favorite item to local storage
|
||||
load Enable a downloaded favorite item
|
||||
unload Disable a favorite item
|
||||
uninstall Remove a local favorite item
|
||||
|
||||
Options:
|
||||
--type skill|agent|command|mcp Filter by item type
|
||||
--format table|json Output format (default: table)
|
||||
|
||||
Examples:
|
||||
/favorite list
|
||||
/favorite list --type skill
|
||||
/favorite view my-skill
|
||||
/favorite download my-skill
|
||||
/favorite load my-skill
|
||||
/favorite unload my-skill
|
||||
`
|
||||
|
||||
export const call: LocalCommandCall = async (args): Promise<LocalCommandResult> => {
|
||||
const parsed = parseFavoriteArgs(args)
|
||||
|
||||
if (!parsed.command || parsed.command === 'help' || parsed.command === '--help' || parsed.command === '-h') {
|
||||
return { type: 'text', value: HELP_TEXT }
|
||||
}
|
||||
|
||||
if (parsed.command === 'list') {
|
||||
const output = await printFavoriteList(parsed.format, parsed.type)
|
||||
return { type: 'text', value: output }
|
||||
}
|
||||
|
||||
if (!parsed.id) {
|
||||
return { type: 'text', value: `Error: favorite id/slug is required\n\n${HELP_TEXT}` }
|
||||
}
|
||||
|
||||
if (parsed.command === 'view') {
|
||||
const output = await printFavoriteView(parsed.id, parsed.format)
|
||||
return { type: 'text', value: output }
|
||||
}
|
||||
|
||||
if (['download', 'load', 'unload', 'uninstall'].includes(parsed.command)) {
|
||||
try {
|
||||
const output = await runFavoriteAction(parsed.command as 'download' | 'load' | 'unload' | 'uninstall', parsed.id)
|
||||
return { type: 'text', value: output }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { type: 'text', value: `Error: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'text', value: `Unknown command: ${parsed.command}\n\n${HELP_TEXT}` }
|
||||
}
|
||||
11
src/commands/favorite/index.ts
Normal file
11
src/commands/favorite/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { Command } from '../../commands.js'
|
||||
|
||||
const favorite = {
|
||||
type: 'local',
|
||||
name: 'favorite',
|
||||
description: 'Manage CoStrict cloud favorite items (skills, agents, commands, MCPs)',
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./favorite.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default favorite
|
||||
664
src/costrict/favorite/favorite.ts
Normal file
664
src/costrict/favorite/favorite.ts
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
import path from 'node:path'
|
||||
import { mkdir, readFile, readdir, rm, writeFile, copyFile } from 'node:fs/promises'
|
||||
import { createCoStrictFetch } from '../provider/fetch.js'
|
||||
import { getCoStrictBaseURL } from '../provider/auth.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { saveGlobalConfig, getGlobalConfig } from '../../utils/config.js'
|
||||
import { clearSkillCaches } from '../../skills/loadSkillsDir.js'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import type { McpServerConfig } from '../../services/mcp/types.js'
|
||||
|
||||
const FAVORITE_PAGE_SIZE = 100
|
||||
const FAVORITE_MAX_PAGES = 20
|
||||
|
||||
export type FavoriteItemType = 'skill' | 'agent' | 'command' | 'mcp'
|
||||
|
||||
type FavoriteLifecycle = 'downloaded' | 'active' | 'unloaded'
|
||||
|
||||
type FavoriteStateRecord = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
itemType: FavoriteItemType
|
||||
localPath: string
|
||||
lifecycle: FavoriteLifecycle
|
||||
installedAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type FavoriteState = {
|
||||
items: Record<string, FavoriteStateRecord>
|
||||
}
|
||||
|
||||
export type FavoriteItem = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
itemType: FavoriteItemType
|
||||
content: string
|
||||
category?: string
|
||||
version?: string
|
||||
favoriteCount?: number
|
||||
favorited?: boolean
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type FavoriteStatus = 'Cloud' | 'Downloaded' | 'Active' | 'Unloaded'
|
||||
|
||||
export type FavoriteItemWithStatus = FavoriteItem & {
|
||||
status: FavoriteStatus
|
||||
localPath?: string
|
||||
}
|
||||
|
||||
type RemoteListResponse = {
|
||||
items?: Array<{ id: string } & Record<string, unknown>>
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
const STORE_TYPE_MAP: Record<string, FavoriteItemType> = {
|
||||
skill: 'skill',
|
||||
subagent: 'agent',
|
||||
command: 'command',
|
||||
mcp: 'mcp',
|
||||
}
|
||||
|
||||
const LOCAL_TO_STORE_TYPE: Record<FavoriteItemType, string> = {
|
||||
skill: 'skill',
|
||||
agent: 'subagent',
|
||||
command: 'command',
|
||||
mcp: 'mcp',
|
||||
}
|
||||
|
||||
function favoriteRoot() {
|
||||
return path.join(getClaudeConfigHomeDir(), 'favorites')
|
||||
}
|
||||
|
||||
function favoriteTypeRoot(itemType: FavoriteItemType) {
|
||||
switch (itemType) {
|
||||
case 'skill':
|
||||
return path.join(favoriteRoot(), 'skills')
|
||||
case 'agent':
|
||||
return path.join(favoriteRoot(), 'agents')
|
||||
case 'command':
|
||||
return path.join(favoriteRoot(), 'commands')
|
||||
case 'mcp':
|
||||
return path.join(favoriteRoot(), 'mcps')
|
||||
}
|
||||
}
|
||||
|
||||
function favoriteStatePath() {
|
||||
return path.join(favoriteRoot(), 'state.json')
|
||||
}
|
||||
|
||||
function skillsDir() {
|
||||
return path.join(getClaudeConfigHomeDir(), 'skills')
|
||||
}
|
||||
|
||||
async function readState(): Promise<FavoriteState> {
|
||||
try {
|
||||
const text = await readFile(favoriteStatePath(), 'utf-8')
|
||||
return JSON.parse(text) as FavoriteState
|
||||
} catch {
|
||||
return { items: {} }
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(state: FavoriteState) {
|
||||
await mkdir(favoriteRoot(), { recursive: true })
|
||||
await writeFile(favoriteStatePath(), JSON.stringify(state, null, 2) + '\n')
|
||||
}
|
||||
|
||||
async function mutateState(fn: (state: FavoriteState) => void | Promise<void>) {
|
||||
const state = await readState()
|
||||
await fn(state)
|
||||
await writeState(state)
|
||||
}
|
||||
|
||||
async function listRemoteCandidates(storeType?: string, extraParams?: Record<string, string>) {
|
||||
const baseUrl = getCoStrictBaseURL()
|
||||
const costrictFetch = createCoStrictFetch()
|
||||
const result: Array<{ id: string } & Record<string, unknown>> = []
|
||||
|
||||
for (let page = 1; page <= FAVORITE_MAX_PAGES; page++) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(FAVORITE_PAGE_SIZE),
|
||||
})
|
||||
if (storeType) params.set('type', storeType)
|
||||
if (extraParams) {
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
params.set(key, value)
|
||||
}
|
||||
}
|
||||
const url = `${baseUrl}/api/items?${params.toString()}`
|
||||
const response = await costrictFetch(url)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`Request failed: ${response.status} ${text}`)
|
||||
}
|
||||
const data = (await response.json()) as RemoteListResponse
|
||||
const items = data.items ?? []
|
||||
result.push(...items)
|
||||
if (!data.hasMore || items.length === 0) break
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function parseFavoriteListItem(data: Record<string, unknown>): FavoriteItem | undefined {
|
||||
const storeType = String(data.itemType ?? '')
|
||||
const localType = STORE_TYPE_MAP[storeType]
|
||||
if (!localType) return undefined
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
slug: String(data.slug ?? data.id),
|
||||
name: String(data.name ?? data.slug ?? data.id),
|
||||
description: String(data.description ?? ''),
|
||||
itemType: localType,
|
||||
content: '',
|
||||
category: typeof data.category === 'string' ? data.category : undefined,
|
||||
version: typeof data.version === 'string' ? data.version : undefined,
|
||||
favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined,
|
||||
favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined,
|
||||
createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined,
|
||||
createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined,
|
||||
updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteItem(id: string): Promise<FavoriteItem> {
|
||||
const baseUrl = getCoStrictBaseURL()
|
||||
const costrictFetch = createCoStrictFetch()
|
||||
const response = await costrictFetch(`${baseUrl}/api/items/${id}`)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`Request failed: ${response.status} ${text}`)
|
||||
}
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const storeType = String(data.itemType ?? '')
|
||||
const localType = STORE_TYPE_MAP[storeType]
|
||||
if (!localType) {
|
||||
throw new Error(`Unsupported item type: ${storeType} (${String(data.slug ?? id)})`)
|
||||
}
|
||||
return {
|
||||
id: String(data.id),
|
||||
slug: String(data.slug),
|
||||
name: String(data.name),
|
||||
description: String(data.description ?? ''),
|
||||
itemType: localType,
|
||||
content: String(data.content ?? ''),
|
||||
category: typeof data.category === 'string' ? data.category : undefined,
|
||||
version: typeof data.version === 'string' ? data.version : undefined,
|
||||
favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined,
|
||||
favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined,
|
||||
createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined,
|
||||
createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined,
|
||||
updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function getActiveSkillSlugs(): Promise<Set<string>> {
|
||||
const sDir = skillsDir()
|
||||
const result = new Set<string>()
|
||||
try {
|
||||
const entries = await readdir(sDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
result.add(entry.name)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skills dir doesn't exist
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function getActiveAgentNames(): Promise<Set<string>> {
|
||||
const cfg = getGlobalConfig()
|
||||
return new Set(Object.keys(cfg.agents ?? {}))
|
||||
}
|
||||
|
||||
async function getActiveCommandNames(): Promise<Set<string>> {
|
||||
const cfg = getGlobalConfig()
|
||||
return new Set(Object.keys(cfg.commands ?? {}))
|
||||
}
|
||||
|
||||
async function getActiveMcpNames(): Promise<Set<string>> {
|
||||
const cfg = getGlobalConfig()
|
||||
return new Set(Object.keys(cfg.mcpServers ?? {}))
|
||||
}
|
||||
|
||||
function deriveStatus(
|
||||
state: FavoriteStateRecord | undefined,
|
||||
activeSkillSlugs: Set<string>,
|
||||
activeAgentNames: Set<string>,
|
||||
activeCommandNames: Set<string>,
|
||||
activeMcpNames: Set<string>,
|
||||
): FavoriteStatus {
|
||||
if (!state) return 'Cloud'
|
||||
switch (state.itemType) {
|
||||
case 'skill':
|
||||
if (activeSkillSlugs.has(state.slug)) return 'Active'
|
||||
break
|
||||
case 'agent':
|
||||
if (activeAgentNames.has(state.slug)) return 'Active'
|
||||
break
|
||||
case 'command':
|
||||
if (activeCommandNames.has(state.slug)) return 'Active'
|
||||
break
|
||||
case 'mcp':
|
||||
if (activeMcpNames.has(state.slug)) return 'Active'
|
||||
break
|
||||
}
|
||||
switch (state.lifecycle) {
|
||||
case 'unloaded':
|
||||
return 'Unloaded'
|
||||
default:
|
||||
return 'Downloaded'
|
||||
}
|
||||
}
|
||||
|
||||
async function persistInstalledItem(item: FavoriteItem) {
|
||||
const typeRoot = favoriteTypeRoot(item.itemType)
|
||||
const dir = path.join(typeRoot, item.slug)
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
switch (item.itemType) {
|
||||
case 'skill':
|
||||
await writeFile(path.join(dir, 'SKILL.md'), item.content)
|
||||
break
|
||||
case 'agent':
|
||||
await writeFile(path.join(dir, `${item.slug}.md`), item.content)
|
||||
break
|
||||
case 'command':
|
||||
await writeFile(path.join(dir, `${item.slug}.md`), item.content)
|
||||
break
|
||||
case 'mcp': {
|
||||
const mcpDestPath = path.join(dir, 'mcp.json')
|
||||
try {
|
||||
const mcpConfig = JSON.parse(item.content)
|
||||
await writeFile(mcpDestPath, JSON.stringify(mcpConfig, null, 2) + '\n')
|
||||
} catch {
|
||||
await writeFile(mcpDestPath, item.content)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(dir, 'item.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
itemType: item.itemType,
|
||||
category: item.category,
|
||||
version: item.version,
|
||||
favoriteCount: item.favoriteCount,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
)
|
||||
|
||||
await mutateState((state) => {
|
||||
state.items[item.slug] = {
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
itemType: item.itemType,
|
||||
localPath: dir,
|
||||
lifecycle: state.items[item.slug]?.lifecycle ?? 'downloaded',
|
||||
installedAt: state.items[item.slug]?.installedAt ?? new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise<boolean> {
|
||||
const contentPath = (() => {
|
||||
switch (hit.itemType) {
|
||||
case 'skill':
|
||||
return path.join(hit.localPath, 'SKILL.md')
|
||||
case 'agent':
|
||||
return path.join(hit.localPath, `${hit.slug}.md`)
|
||||
case 'command':
|
||||
return path.join(hit.localPath, `${hit.slug}.md`)
|
||||
case 'mcp':
|
||||
return path.join(hit.localPath, 'mcp.json')
|
||||
}
|
||||
})()
|
||||
try {
|
||||
const { size } = await import('node:fs/promises').then((m) => m.stat(contentPath))
|
||||
return size > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureInstalled(slugOrId: string): Promise<FavoriteStateRecord> {
|
||||
const state = await readState()
|
||||
const hit = state.items[slugOrId] ?? Object.values(state.items).find((item) => item.id === slugOrId)
|
||||
if (hit && (await hasUsableLocalContent(hit))) return hit
|
||||
|
||||
let remote = hit ? await getRemoteItem(hit.id).catch(() => undefined) : undefined
|
||||
if (!remote) {
|
||||
const favorites = await listFavoriteItems()
|
||||
const listed = favorites.find((f) => f.slug === slugOrId || f.id === slugOrId)
|
||||
if (!listed) throw new Error(`Favorite item not found: ${slugOrId}`)
|
||||
remote = await getRemoteItem(listed.id)
|
||||
}
|
||||
|
||||
const localPath = await persistInstalledItem(remote)
|
||||
return {
|
||||
id: remote.id,
|
||||
slug: remote.slug,
|
||||
name: remote.name,
|
||||
itemType: remote.itemType,
|
||||
localPath,
|
||||
lifecycle: 'downloaded',
|
||||
installedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function convertMcpConfig(config: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (typeof config.type === 'string' && (config.type === 'local' || config.type === 'remote')) {
|
||||
return config
|
||||
}
|
||||
if (config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)) {
|
||||
const servers = Object.entries(config.mcpServers as Record<string, unknown>)
|
||||
if (servers.length === 0) return undefined
|
||||
const [, server] = servers[0]
|
||||
if (server && typeof server === 'object') {
|
||||
return convertSingleMcpServer(server as Record<string, unknown>)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return convertSingleMcpServer(config)
|
||||
}
|
||||
|
||||
function convertSingleMcpServer(server: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const command = server.command
|
||||
const args = server.args
|
||||
if (!command && !args) return undefined
|
||||
const cmdArray: string[] = []
|
||||
if (typeof command === 'string') {
|
||||
cmdArray.push(command)
|
||||
} else if (Array.isArray(command)) {
|
||||
for (const c of command) {
|
||||
if (typeof c === 'string') cmdArray.push(c)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(args)) {
|
||||
for (const arg of args) {
|
||||
if (typeof arg === 'string') cmdArray.push(arg)
|
||||
}
|
||||
}
|
||||
if (cmdArray.length === 0) return undefined
|
||||
const result: Record<string, unknown> = {
|
||||
type: 'local',
|
||||
command: cmdArray,
|
||||
}
|
||||
if (typeof server.environment === 'object' && server.environment !== null) {
|
||||
result.environment = server.environment
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function copyDir(src: string, dest: string) {
|
||||
await mkdir(dest, { recursive: true })
|
||||
const entries = await readdir(src, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name)
|
||||
const destPath = path.join(dest, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(srcPath, destPath)
|
||||
} else {
|
||||
await copyFile(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addItemToConfig(item: FavoriteItem, localPath: string) {
|
||||
switch (item.itemType) {
|
||||
case 'skill': {
|
||||
const destDir = path.join(skillsDir(), item.slug)
|
||||
await mkdir(skillsDir(), { recursive: true })
|
||||
await copyDir(localPath, destDir)
|
||||
clearSkillCaches()
|
||||
break
|
||||
}
|
||||
case 'agent': {
|
||||
const mdPath = path.join(localPath, `${item.slug}.md`)
|
||||
const content = await readFile(mdPath, 'utf-8')
|
||||
const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath)
|
||||
saveGlobalConfig((cfg) => ({
|
||||
...cfg,
|
||||
agents: {
|
||||
...(cfg.agents ?? {}),
|
||||
[item.slug]: {
|
||||
...(frontmatter as Record<string, unknown>),
|
||||
prompt: markdownContent.trim(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
break
|
||||
}
|
||||
case 'command': {
|
||||
const mdPath = path.join(localPath, `${item.slug}.md`)
|
||||
const content = await readFile(mdPath, 'utf-8')
|
||||
const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath)
|
||||
saveGlobalConfig((cfg) => ({
|
||||
...cfg,
|
||||
commands: {
|
||||
...(cfg.commands ?? {}),
|
||||
[item.slug]: {
|
||||
...(frontmatter as Record<string, unknown>),
|
||||
template: markdownContent.trim(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
break
|
||||
}
|
||||
case 'mcp': {
|
||||
const mcpJsonPath = path.join(localPath, 'mcp.json')
|
||||
const configJson = JSON.parse(await readFile(mcpJsonPath, 'utf-8')) as Record<string, unknown>
|
||||
const converted = convertMcpConfig(configJson)
|
||||
if (!converted) {
|
||||
throw new Error(
|
||||
`Unable to recognize MCP configuration format: ${item.slug}\n\n` +
|
||||
`Configuration file: ${mcpJsonPath}\n` +
|
||||
`Supported formats: opencode native, VS Code / Claude Desktop style, or simplified { command, args }`,
|
||||
)
|
||||
}
|
||||
saveGlobalConfig((cfg) => ({
|
||||
...cfg,
|
||||
mcpServers: {
|
||||
...(cfg.mcpServers ?? {}),
|
||||
[item.slug]: converted as unknown as McpServerConfig,
|
||||
},
|
||||
}))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _localPath: string) {
|
||||
switch (itemType) {
|
||||
case 'skill': {
|
||||
const destDir = path.join(skillsDir(), slug)
|
||||
await rm(destDir, { recursive: true, force: true })
|
||||
clearSkillCaches()
|
||||
break
|
||||
}
|
||||
case 'agent': {
|
||||
saveGlobalConfig((cfg) => {
|
||||
const agents = { ...(cfg.agents ?? {}) }
|
||||
delete agents[slug]
|
||||
return { ...cfg, agents }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'command': {
|
||||
saveGlobalConfig((cfg) => {
|
||||
const commands = { ...(cfg.commands ?? {}) }
|
||||
delete commands[slug]
|
||||
return { ...cfg, commands }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'mcp': {
|
||||
saveGlobalConfig((cfg) => {
|
||||
const mcpServers = { ...(cfg.mcpServers ?? {}) }
|
||||
delete mcpServers[slug]
|
||||
return { ...cfg, mcpServers }
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readItemForConfig(installed: FavoriteStateRecord): Promise<FavoriteItem> {
|
||||
const itemMetaPath = path.join(installed.localPath, 'item.json')
|
||||
let itemMeta: Record<string, unknown> = {}
|
||||
try {
|
||||
const text = await readFile(itemMetaPath, 'utf-8')
|
||||
itemMeta = JSON.parse(text) as Record<string, unknown>
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
id: installed.id,
|
||||
slug: installed.slug,
|
||||
name: installed.name,
|
||||
description: String(itemMeta.description ?? ''),
|
||||
itemType: installed.itemType,
|
||||
content: '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFavoriteItems(type?: FavoriteItemType): Promise<FavoriteItemWithStatus[]> {
|
||||
const storeTypes = type ? [LOCAL_TO_STORE_TYPE[type]] : Object.values(LOCAL_TO_STORE_TYPE)
|
||||
const candidatePages = await Promise.all(
|
||||
[...new Set(storeTypes)].map(async (st) =>
|
||||
listRemoteCandidates(st, { favorited: 'true' }).catch((error) => {
|
||||
console.warn('failed to fetch remote favorite candidates', { type: st, error })
|
||||
return []
|
||||
}),
|
||||
),
|
||||
)
|
||||
const candidates = candidatePages.flat()
|
||||
|
||||
const [activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames, state] = await Promise.all([
|
||||
getActiveSkillSlugs(),
|
||||
getActiveAgentNames(),
|
||||
getActiveCommandNames(),
|
||||
getActiveMcpNames(),
|
||||
readState(),
|
||||
])
|
||||
|
||||
const seen = new Set<string>()
|
||||
const result: FavoriteItemWithStatus[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const item = parseFavoriteListItem(candidate)
|
||||
if (!item) continue
|
||||
if (!item?.favorited) continue
|
||||
if (seen.has(item.slug)) continue
|
||||
seen.add(item.slug)
|
||||
const local = state.items[item.slug]
|
||||
result.push({
|
||||
...item,
|
||||
status: deriveStatus(local, activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames),
|
||||
localPath: local?.localPath,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
for (const [slug, record] of Object.entries(state.items)) {
|
||||
if (type && record.itemType !== type) continue
|
||||
if (seen.has(slug)) continue
|
||||
seen.add(slug)
|
||||
result.push({
|
||||
id: record.id,
|
||||
slug: record.slug,
|
||||
name: record.name,
|
||||
description: '',
|
||||
itemType: record.itemType,
|
||||
content: '',
|
||||
status: deriveStatus(record, activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames),
|
||||
localPath: record.localPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export async function viewFavoriteItem(slugOrId: string): Promise<FavoriteItemWithStatus> {
|
||||
const favorites = await listFavoriteItems()
|
||||
const item = favorites.find((f) => f.slug === slugOrId || f.id === slugOrId)
|
||||
if (!item) throw new Error(`Favorite item not found: ${slugOrId}`)
|
||||
|
||||
const detail = await getRemoteItem(item.id)
|
||||
return {
|
||||
...detail,
|
||||
status: item.status,
|
||||
localPath: item.localPath,
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadFavoriteItem(slugOrId: string) {
|
||||
const item = await viewFavoriteItem(slugOrId)
|
||||
const localPath = await persistInstalledItem(item)
|
||||
await mutateState((state) => {
|
||||
const record = state.items[item.slug]
|
||||
record.lifecycle = 'downloaded'
|
||||
record.updatedAt = new Date().toISOString()
|
||||
record.localPath = localPath
|
||||
})
|
||||
return { ...item, status: 'Downloaded' as const, localPath }
|
||||
}
|
||||
|
||||
export async function loadFavoriteItem(slugOrId: string) {
|
||||
const installed = await ensureInstalled(slugOrId)
|
||||
const itemForConfig = await readItemForConfig(installed)
|
||||
await addItemToConfig(itemForConfig, installed.localPath)
|
||||
await mutateState((state) => {
|
||||
const record = state.items[installed.slug]
|
||||
record.lifecycle = 'active'
|
||||
record.updatedAt = new Date().toISOString()
|
||||
})
|
||||
return installed
|
||||
}
|
||||
|
||||
export async function unloadFavoriteItem(slugOrId: string) {
|
||||
const installed = await ensureInstalled(slugOrId)
|
||||
await removeItemFromConfig(installed.itemType, installed.slug, installed.localPath)
|
||||
await mutateState((state) => {
|
||||
const record = state.items[installed.slug]
|
||||
record.lifecycle = 'unloaded'
|
||||
record.updatedAt = new Date().toISOString()
|
||||
})
|
||||
return installed
|
||||
}
|
||||
|
||||
export async function uninstallFavoriteItem(slugOrId: string) {
|
||||
const installed = await ensureInstalled(slugOrId)
|
||||
await removeItemFromConfig(installed.itemType, installed.slug, installed.localPath)
|
||||
await rm(installed.localPath, { recursive: true, force: true })
|
||||
await mutateState((state) => {
|
||||
delete state.items[installed.slug]
|
||||
})
|
||||
return installed
|
||||
}
|
||||
10
src/main.tsx
10
src/main.tsx
|
|
@ -3975,7 +3975,15 @@ async function run(): Promise<CommanderCommand> {
|
|||
commands: mcpCommands,
|
||||
tools: mcpTools,
|
||||
},
|
||||
toolPermissionContext,
|
||||
// In headless (-p) mode the TUI is never mounted, so
|
||||
// AskUserQuestionPermissionRequest's useEffect/setTimeout never fires.
|
||||
// Mark shouldAvoidPermissionPrompts so permissions.ts step 1e-headless
|
||||
// picks up askUserQuestionTimeoutSeconds from settings and applies the
|
||||
// configured auto-select timeout instead of hanging indefinitely.
|
||||
toolPermissionContext: {
|
||||
...toolPermissionContext,
|
||||
shouldAvoidPermissionPrompts: true,
|
||||
},
|
||||
effortValue:
|
||||
parseEffortValue(options.effort) ??
|
||||
getInitialEffortSetting(),
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
|
|||
import { count } from './utils/array.js'
|
||||
import { createTrace, endTrace, isLangfuseEnabled } from './services/langfuse/index.js'
|
||||
import { getAPIProvider } from './utils/model/providers.js'
|
||||
import { uploadSessionTurn } from './utils/sessionDataUploader.js'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const snipModule = feature('HISTORY_SNIP')
|
||||
|
|
@ -909,6 +910,13 @@ async function* queryLoop(
|
|||
}
|
||||
queryCheckpoint('query_api_streaming_end')
|
||||
|
||||
// Report conversation/summary/commits to CoStrict raw-dump endpoint.
|
||||
// Fire-and-forget; non-blocking.
|
||||
const lastAssistant = assistantMessages.at(-1)
|
||||
if (lastAssistant) {
|
||||
uploadSessionTurn(getSessionId(), String(lastAssistant.uuid))
|
||||
}
|
||||
|
||||
// Yield deferred microcompact boundary message using actual API-reported
|
||||
// token deletion count instead of client-side estimates.
|
||||
// Entire block gated behind feature() so the excluded string
|
||||
|
|
|
|||
314
src/services/rawDump/README.md
Normal file
314
src/services/rawDump/README.md
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# Raw Dump 数据上报模块
|
||||
|
||||
## 概述
|
||||
|
||||
本模块负责将 csc 的会话数据(Conversation、Summary、Commits)上报到 CoStrict 服务端,用于统计分析。
|
||||
|
||||
**设计原则:**
|
||||
- **与框架解耦**:不依赖 React、Effect-TS、Ink 等任何 UI 框架
|
||||
- **非阻塞**:主进程只写入队列,由独立 batch worker 顺序消费,不阻塞主流程
|
||||
- **防限流**:队列 + 单 worker 顺序执行 + 请求间延迟 + 随机抖动,避免并发 429
|
||||
- **协议兼容**:与 opencode 的 raw-dump 插件保持接口对齐
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/services/rawDump/
|
||||
├── README.md # 本文档
|
||||
├── types.ts # 类型定义 + 环境变量常量
|
||||
├── state.ts # 磁盘状态管理(去重)
|
||||
├── git.ts # Git 辅助函数封装
|
||||
├── worker.ts # 实际上报逻辑(被 batch worker 调用)
|
||||
├── queue.ts # 文件队列(主进程写入,worker 消费)
|
||||
├── batchWorker.ts # 独立 batch worker 进程(顺序消费队列)
|
||||
├── spawn.ts # 子进程启动器
|
||||
└── index.ts # 主入口 API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 上报流程
|
||||
|
||||
```
|
||||
主进程:assistant message 完成
|
||||
→ reportTurn(sessionID, messageID, directory)
|
||||
→ enqueue({ sessionID, messageID, directory }) 写入队列文件
|
||||
→ ensureBatchWorker() 启动 detached batch worker(仅一次)
|
||||
|
||||
Batch Worker 进程(独立,每 30s + 随机抖动检查一次):
|
||||
→ acquireLock() # 文件锁,防止多 worker 并发
|
||||
→ readQueue() # 读取队列文件
|
||||
→ dedup tasks # 同一个 session 的多个 task 只保留最新一个
|
||||
→ for each task:
|
||||
→ auth() # 加载凭证、刷新 token
|
||||
→ loadSessionMessages() # 从 JSONL 加载会话消息
|
||||
→ uploadConversation() → POST /raw-store/task-conversation
|
||||
→ uploadSummary() → POST /raw-store/task-summary
|
||||
→ uploadCommits() → POST /raw-store/commit(逐条更新 state)
|
||||
→ writeState(state) # finally 中执行,确保 state 一定写入
|
||||
→ clearQueue() # 清空队列
|
||||
→ releaseLock()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 触发时机
|
||||
|
||||
每完成一轮 assistant 回复,在上报点调用:
|
||||
|
||||
```typescript
|
||||
import { reportTurn } from './services/rawDump/index.js'
|
||||
|
||||
// 参数说明:
|
||||
// sessionID - 当前会话 ID
|
||||
// messageID - 刚完成的 assistant message UUID
|
||||
// directory - 工作目录(用于 git diff 和 repo 信息)
|
||||
reportTurn(sessionId, assistantMessage.uuid, cwd)
|
||||
```
|
||||
|
||||
**推荐集成点:**
|
||||
|
||||
1. `src/query.ts` 中 streaming 结束后(`query_api_streaming_end` 之后)
|
||||
2. `src/utils/sessionDataUploader.ts` 已提供 `uploadSessionTurn()` 封装
|
||||
|
||||
---
|
||||
|
||||
## 数据映射(csc → 上报格式)
|
||||
|
||||
### Conversation(单轮对话)
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|-----|------|------|
|
||||
| `task_id` | `sessionID` | 会话唯一标识 |
|
||||
| `request_id` | `message.id` 或 `message.uuid` | assistant message ID |
|
||||
| `model` | `assistant.message.model` | 使用的模型 |
|
||||
| `mode` | `assistant.mode` / `assistant.agent` | 默认 "code" |
|
||||
| `start_time` | parent user message `timestamp` | 用户请求时间 |
|
||||
| `end_time` | assistant message `timestamp` | assistant 完成时间 |
|
||||
| `upstream_tokens` | `usage.input + cache_read + cache_creation` | 输入 token 总量 |
|
||||
| `downstream_tokens` | `usage.output` | 输出 token 量 |
|
||||
| `request_content` | user message text content | 用户请求文本 |
|
||||
| `response_content` | assistant text content | assistant 回复文本 |
|
||||
| `diff` | tool_use diff → fallback `git diff HEAD` | 本轮代码变更 |
|
||||
| `error_code` | error name 映射 | 401/413/499/500 |
|
||||
|
||||
### Summary(会话汇总)
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|-----|------|------|
|
||||
| `task_id` | `sessionID` | 会话唯一标识 |
|
||||
| `start_time` | 第一条消息 `timestamp` | 会话开始时间 |
|
||||
| `end_time` | 最后一条消息 `timestamp` | 会话最后更新时间 |
|
||||
| `upstream_tokens` | 所有 assistant messages 累计 | 会话总输入 token |
|
||||
| `downstream_tokens` | 所有 assistant messages 累计 | 会话总输出 token |
|
||||
| `user_id` | refresh_token JWT `universal_id` | 用户唯一标识 |
|
||||
| `repo_addr` | `git remote get-url origin` | 仓库地址 |
|
||||
| `repo_branch` | `git branch --show-current` | 当前分支 |
|
||||
| `diff` | `git diff HEAD` | 工作区完整变更 |
|
||||
|
||||
### Commits(Git 提交)
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|-----|------|------|
|
||||
| `commit_id` | `git log` | commit hash |
|
||||
| `commit_time` | `git log %aI` | 作者时间(ISO) |
|
||||
| `diff` | `git show --diff-filter=ACDMR` | 变更内容 |
|
||||
| `comment` | `subject.slice(0, 150)` | 截断后的提交信息 |
|
||||
|
||||
---
|
||||
|
||||
## Diff 获取策略
|
||||
|
||||
csc 没有 opencode 中的 `step-start`/`step-finish` snapshot 机制,采用以下策略:
|
||||
|
||||
### Conversation diff
|
||||
1. **优先**:从 assistant message 的 `tool_use` blocks 中提取 `input.content` / `new_string` / `diff` / `patch`
|
||||
2. **Fallback**:执行 `git diff HEAD` 获取当前工作区未提交的变更
|
||||
|
||||
### Summary diff
|
||||
- 直接执行 `git diff HEAD`,获取整个工作区相对于最新 commit 的变更
|
||||
|
||||
### Commits diff
|
||||
- 逐个 commit 执行 `git show --diff-filter=ACDMR`(仅包含新增/修改/删除/重命名)
|
||||
|
||||
---
|
||||
|
||||
## 去重机制
|
||||
|
||||
### 1. 队列去重(进程内)
|
||||
同一个 session + messageID 的多个 task,batch worker 消费时只保留最新一个:
|
||||
```typescript
|
||||
const key = `${task.sessionID}:${task.messageID}`
|
||||
const existing = deduped.get(key)
|
||||
if (!existing || task.enqueuedAt > existing.enqueuedAt) {
|
||||
deduped.set(key, task)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Conversation 去重(磁盘)
|
||||
```typescript
|
||||
// ~/.claude/csc-raw-dump-state.json
|
||||
{
|
||||
"conversation": {
|
||||
"taskID:requestID": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Commits 去重(磁盘,逐条更新)
|
||||
```typescript
|
||||
// 以 repo#branch#workDir 为 key
|
||||
{
|
||||
"commits": {
|
||||
"git@github.com:foo/bar.git#main#/Users/xxx/project": "abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **逐 commit 更新**:每成功上传一个 commit,立即更新 `state.commits[stateKey]` 为该 commit 的 hash。即使后续失败,已成功的 commits 不会重复上报。
|
||||
- **获取范围**:
|
||||
- 有 lastCommit:`git log ${lastCommit}..HEAD --max-count=50`
|
||||
- 无 lastCommit:`git log --since=7 days ago --max-count=50`
|
||||
- **批次延迟**:每上传 10 个 commits 后暂停 500ms,避免触发限流
|
||||
|
||||
---
|
||||
|
||||
## 429 防护机制
|
||||
|
||||
1. **队列 + 单 worker**:主进程只 enqueue,只有一个 batch worker 顺序消费,天然避免并发
|
||||
2. **文件锁**:`acquireLock()` / `releaseLock()` 确保同一时刻只有一个 worker 在运行
|
||||
3. **请求重试**:`postJson()` 对 429 和网络错误自动重试 3 次,退避间隔 5s、10s
|
||||
4. **commit 批次延迟**:每 10 个 commits 暂停 500ms
|
||||
5. **随机抖动**:batch worker 启动后首次执行有 0-10s 随机延迟,避免规律性请求
|
||||
6. **commit 数量限制**:单次最多获取 50 个 commits,时间范围限制为 7 天
|
||||
|
||||
---
|
||||
|
||||
## 认证与请求头
|
||||
|
||||
复用已有的 `costrict/provider` 模块:
|
||||
|
||||
```typescript
|
||||
import { loadCoStrictCredentials } from '../../costrict/provider/credentials.js'
|
||||
import { refreshCoStrictToken } from '../../costrict/provider/token.js'
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
- `Authorization: Bearer ${access_token}`
|
||||
- `zgsm-client-id: ${machine_id}`
|
||||
- `zgsm-client-ide: cli`
|
||||
- `X-Costrict-Version: csc-${version}`
|
||||
|
||||
**Token 刷新:** 若 access_token 过期且存在 refresh_token,worker 会自动刷新并回写凭证文件。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|-----|------|--------|
|
||||
| `CSC_DISABLE_RAW_DUMP` | 禁用本模块 | `false` |
|
||||
| `COSTRICT_DISABLE_RAW_DUMP` | 兼容 opencode 的禁用开关 | `false` |
|
||||
| `CSC_RAW_DUMP_DEBUG` | 开启调试日志(`1` 或 `true`) | `false`(默认关闭) |
|
||||
| `CSC_RAW_DUMP_BASE_URL` | 自定义上报 base URL | 从凭证读取 |
|
||||
| `COSTRICT_RAW_DUMP_BASE_URL` | 兼容 opencode 的自定义 URL | 从凭证读取 |
|
||||
| `COSTRICT_BASE_URL` | CoStrict 服务地址 | `https://zgsm.sangfor.com` |
|
||||
|
||||
---
|
||||
|
||||
## 状态文件与日志文件
|
||||
|
||||
### 状态文件
|
||||
```
|
||||
~/.claude/csc-raw-dump-state.json
|
||||
```
|
||||
|
||||
内容格式:
|
||||
```json
|
||||
{
|
||||
"conversation": {
|
||||
"session-id-1:msg-uuid-1": true,
|
||||
"session-id-1:msg-uuid-2": true
|
||||
},
|
||||
"commits": {
|
||||
"git@github.com:org/repo.git#main#/Users/xxx/code/repo": "abc123def"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 日志文件
|
||||
```
|
||||
~/.claude/csc-raw-dump.log
|
||||
```
|
||||
|
||||
主进程和 batch worker 的日志都追加写入该文件。由于 worker 是 detached 进程(`stdio: 'ignore'`),日志只能通过文件查看。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项与待完善项
|
||||
|
||||
1. **Cost 计算**
|
||||
当前 `cost` 字段设为 0。需接入 `src/cost-tracker.ts` 的 `calculateUSDCost()` 或从 `bootstrap/state.ts` 获取每轮/累计 cost。
|
||||
|
||||
2. **TTFT 获取**
|
||||
当前从 assistant message 的 `ttftMs` 字段读取。需确认 csc 是否在 message 对象上保存了该值,否则需要在 streaming 开始时手动计时。
|
||||
|
||||
3. **会话目录**
|
||||
`getSessionDirectory()` 使用启发式查找(`~/.claude/projects/{normalizedPath}` 等)。csc 实际会话 JSONL 存放路径为 `~/.claude/projects/{sanitizePath(cwd)}/{sessionId}.jsonl`。
|
||||
|
||||
4. **User 消息关联**
|
||||
当前按消息列表顺序查找前一个 `type === 'user'` 的消息。若 csc 存在明确的 parent-child 关系,应改用 `parentID` 或类似字段。
|
||||
|
||||
5. **Model 信息**
|
||||
`model` 字段取自 `assistant.message.model`。若该字段不可靠,可从 `bootstrap/state.ts` 的 `getCurrentModel()` 获取。
|
||||
|
||||
6. **Sender 识别**
|
||||
当前固定为 `"user"`。若 csc 支持 agent/agentic 模式,需根据消息来源判断 `"user"` 或 `"agent"`。
|
||||
|
||||
---
|
||||
|
||||
## 与 opencode 的差异对比
|
||||
|
||||
| 项 | opencode | csc(本模块) |
|
||||
|---|---------|-------------|
|
||||
| 消息结构 | `parts` + `step-start/step-finish` snapshot | `message.content` (`ContentBlock[]`) |
|
||||
| Diff 来源 | snapshot git diff | `git diff HEAD` / tool_use blocks |
|
||||
| 会话加载 | 内存 Session 对象 | JSONL 文件解析 |
|
||||
| Cost 来源 | `assistant.info.cost` | 待接入 cost-tracker |
|
||||
| 运行时 | Effect-TS | Bun + 纯 Node.js API |
|
||||
| 上报模式 | 单条即时上报 | 队列 + batch worker 顺序消费 |
|
||||
| 限流防护 | 无 | 队列 + 单 worker + 重试 + 批次延迟 + 抖动 |
|
||||
| 凭证路径 | `~/.costrict/credentials.json` | `~/.claude/csc-auth.json` |
|
||||
|
||||
---
|
||||
|
||||
## 调试
|
||||
|
||||
调试日志默认关闭,通过环境变量开启:
|
||||
|
||||
```bash
|
||||
# 开启调试日志
|
||||
export CSC_RAW_DUMP_DEBUG=1
|
||||
|
||||
# 查看日志
|
||||
tail -f ~/.claude/csc-raw-dump.log
|
||||
```
|
||||
|
||||
关键日志标识:
|
||||
- `[raw-dump:info]` / `[raw-dump:debug]` — worker.ts 中的日志
|
||||
- `[raw-dump-batch:info]` / `[raw-dump-batch:debug]` — batchWorker.ts 中的日志
|
||||
|
||||
日志模块完全独立(`logger.ts`),默认不产生任何输出,不创建日志文件。
|
||||
|
||||
常用排查命令:
|
||||
```bash
|
||||
# 查看 state 文件
|
||||
cat ~/.claude/csc-raw-dump-state.json
|
||||
|
||||
# 查看队列文件
|
||||
cat ~/.claude/csc-raw-dump-queue.jsonl
|
||||
|
||||
# 查看是否有 worker 在运行(锁文件)
|
||||
cat ~/.claude/csc-raw-dump.lock
|
||||
```
|
||||
149
src/services/rawDump/batchWorker.ts
Normal file
149
src/services/rawDump/batchWorker.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Raw Dump Batch Worker
|
||||
* 顺序消费队列,避免并发 429
|
||||
* 独立进程,通过自循环 setTimeout 严格串行执行
|
||||
*/
|
||||
|
||||
import { uploadConversation, uploadSummary, uploadCommits, auth } from './worker.js'
|
||||
import { readQueue, clearQueue, acquireLock, releaseLock, type QueueTask } from './queue.js'
|
||||
import { readState, writeState } from './state.js'
|
||||
import { getSessionDirectory, loadSessionMessages } from './worker.js'
|
||||
import { getRepoInfo, getWorkingTreeDiff } from './git.js'
|
||||
import { createLogger } from './logger.js'
|
||||
|
||||
const log = createLogger('raw-dump-batch')
|
||||
|
||||
const BATCH_INTERVAL_MS = 30_000 // 每轮间隔
|
||||
// 进程内重入保护:文件锁不防同进程重入,必须用内存 flag 兜底
|
||||
let isRunning = false
|
||||
|
||||
async function processTask(task: QueueTask) {
|
||||
log('info', 'processing task', { sessionID: task.sessionID, messageID: task.messageID })
|
||||
|
||||
const sessionDir = getSessionDirectory(task.directory, task.sessionID)
|
||||
const messages = await loadSessionMessages(sessionDir, task.sessionID, task.messageID)
|
||||
|
||||
if (messages.length === 0) {
|
||||
log('warn', 'no messages found', { sessionDir, sessionID: task.sessionID })
|
||||
}
|
||||
|
||||
const authData = await auth()
|
||||
const state = await readState()
|
||||
|
||||
// 预加载 git 信息,三次上传共享,避免每个 task 重复 spawn 8+ 个 git 进程
|
||||
const repoInfo = await getRepoInfo(task.directory)
|
||||
const workingTreeDiff = await getWorkingTreeDiff(task.directory)
|
||||
|
||||
try {
|
||||
// conversation
|
||||
const conversationUploaded = await uploadConversation(
|
||||
{ sessionID: task.sessionID, messageID: task.messageID, directory: task.directory, messages },
|
||||
authData,
|
||||
state,
|
||||
{ workingTreeDiff },
|
||||
)
|
||||
|
||||
// summary(每个 turn 都报,但内容会累积)
|
||||
await uploadSummary(
|
||||
{ sessionID: task.sessionID, directory: task.directory, messages },
|
||||
authData,
|
||||
{ repoInfo, workingTreeDiff },
|
||||
)
|
||||
|
||||
// commits(限制频率,避免重复上报)
|
||||
await uploadCommits({ directory: task.directory }, authData, state, { repoInfo })
|
||||
|
||||
log('info', 'task completed', { sessionID: task.sessionID, conversationUploaded })
|
||||
} finally {
|
||||
// 无论成功或失败,都写入 state(commits 已逐条更新)
|
||||
await writeState(state)
|
||||
}
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
// 第一道防线:同进程重入保护
|
||||
if (isRunning) {
|
||||
log('debug', 'runBatch already running in-process, skip')
|
||||
return
|
||||
}
|
||||
isRunning = true
|
||||
|
||||
try {
|
||||
// 第二道防线:跨进程文件锁
|
||||
if (!acquireLock()) {
|
||||
log('debug', 'another worker process holds the lock, skip')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = readQueue()
|
||||
if (tasks.length === 0) {
|
||||
log('debug', 'queue empty')
|
||||
return
|
||||
}
|
||||
|
||||
// 第三道防线:读完立刻清空队列
|
||||
// - 处理期间新进来的任务会在下一轮处理
|
||||
// - 即使有意外的并发 runBatch 拿到锁,也只会看到空队列直接返回
|
||||
clearQueue()
|
||||
|
||||
log('info', `processing ${tasks.length} tasks`)
|
||||
|
||||
// 去重:同一个 session 的多个 task,只保留最新的一个
|
||||
const deduped = new Map<string, QueueTask>()
|
||||
for (const task of tasks) {
|
||||
const key = `${task.sessionID}:${task.messageID}`
|
||||
const existing = deduped.get(key)
|
||||
if (!existing || task.enqueuedAt > existing.enqueuedAt) {
|
||||
deduped.set(key, task)
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTasks = Array.from(deduped.values()).sort((a, b) => a.enqueuedAt - b.enqueuedAt)
|
||||
log('info', `deduped to ${uniqueTasks.length} unique tasks`)
|
||||
|
||||
for (const task of uniqueTasks) {
|
||||
try {
|
||||
await processTask(task)
|
||||
} catch (err) {
|
||||
log('error', 'task failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
sessionID: task.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log('info', 'batch completed')
|
||||
} finally {
|
||||
releaseLock()
|
||||
}
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
export function startBatchWorker() {
|
||||
log('info', 'batch worker started', { interval: BATCH_INTERVAL_MS })
|
||||
|
||||
// 自循环 setTimeout:上一轮跑完才安排下一轮,从源头消除并发
|
||||
// 即便 runBatch 抛错也确保下一轮被排上,避免 worker 卡死
|
||||
const scheduleNext = (delay: number) => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await runBatch()
|
||||
} catch (err) {
|
||||
log('error', 'runBatch threw', { error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * 5_000)
|
||||
scheduleNext(BATCH_INTERVAL_MS + jitter)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// 启动时随机抖动 0~10s,避免多个 csc 实例同时起 worker 撞 API
|
||||
scheduleNext(Math.floor(Math.random() * 10_000))
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (process.argv[1]?.includes('batchWorker')) {
|
||||
startBatchWorker()
|
||||
}
|
||||
111
src/services/rawDump/git.ts
Normal file
111
src/services/rawDump/git.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Raw Dump Git 辅助函数
|
||||
* 仅依赖 node:child_process,与框架解耦
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
async function gitExec(args: string[], cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB
|
||||
})
|
||||
return stdout.trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRepoInfo(cwd: string) {
|
||||
const [repoAddr, repoBranch, gitUserName, gitUserEmail] = await Promise.all([
|
||||
gitExec(['remote', 'get-url', 'origin'], cwd),
|
||||
gitExec(['branch', '--show-current'], cwd),
|
||||
gitExec(['config', 'user.name'], cwd),
|
||||
gitExec(['config', 'user.email'], cwd),
|
||||
])
|
||||
|
||||
return {
|
||||
repo_addr: repoAddr,
|
||||
repo_branch: repoBranch,
|
||||
git_user_name: gitUserName,
|
||||
git_user_email: gitUserEmail,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRawDiff(cwd: string, from?: string, to?: string): Promise<string> {
|
||||
if (from && to && from !== to) {
|
||||
return gitExec(['diff', '--no-ext-diff', from, to], cwd)
|
||||
}
|
||||
// Fallback: diff working tree against HEAD
|
||||
return gitExec(['diff', 'HEAD'], cwd)
|
||||
}
|
||||
|
||||
export async function getWorkingTreeDiff(cwd: string): Promise<string> {
|
||||
return gitExec(['diff', 'HEAD'], cwd)
|
||||
}
|
||||
|
||||
export function countDiffLines(diff: string): number {
|
||||
let count = 0
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) count++
|
||||
}
|
||||
if (count === 0 && diff.trim()) return diff.trim().split('\n').length
|
||||
return count
|
||||
}
|
||||
|
||||
export function extractFilesFromDiff(diff: string): string[] {
|
||||
const files = new Set<string>()
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+++ b/')) files.add(line.slice(6).trim())
|
||||
else if (line.startsWith('--- a/')) files.add(line.slice(6).trim())
|
||||
else if (line.startsWith('diff --git ')) {
|
||||
const match = line.match(/^diff --git "?a\/(.+?)"? "?b\/(.+?)"?$/)
|
||||
if (match?.[2]) files.add(match[2])
|
||||
}
|
||||
}
|
||||
return Array.from(files)
|
||||
}
|
||||
|
||||
export function parseCommitLog(output: string): Array<{
|
||||
commit_id: string
|
||||
commit_time: string
|
||||
git_user_name: string
|
||||
git_user_email: string
|
||||
subject: string
|
||||
}> {
|
||||
if (!output.trim()) return []
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const [commit_id, commit_time, git_user_name, git_user_email, ...rest] = line.split('|')
|
||||
if (!commit_id || !git_user_email) return null
|
||||
return { commit_id, commit_time, git_user_name, git_user_email, subject: rest.join('|') }
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => !!item)
|
||||
}
|
||||
|
||||
export async function getCommitLog(cwd: string, lastCommit?: string): Promise<string> {
|
||||
if (lastCommit) {
|
||||
return gitExec(
|
||||
['log', `${lastCommit}..HEAD`, '--max-count=50', '--format=%H|%aI|%an|%ae|%s'],
|
||||
cwd,
|
||||
)
|
||||
}
|
||||
return gitExec(
|
||||
['log', '--since=7 days ago', '--max-count=50', '--format=%H|%aI|%an|%ae|%s'],
|
||||
cwd,
|
||||
)
|
||||
}
|
||||
|
||||
export async function getCommitDiff(cwd: string, commitId: string): Promise<string> {
|
||||
return gitExec(['show', '--format=', '--diff-filter=ACDMR', commitId], cwd)
|
||||
}
|
||||
|
||||
export function toCommitComment(subject: string): string {
|
||||
return Array.from(subject).slice(0, 150).join('')
|
||||
}
|
||||
37
src/services/rawDump/index.ts
Normal file
37
src/services/rawDump/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Raw Dump 主入口
|
||||
* 队列模式:主进程只 enqueue,单 batch worker 顺序消费
|
||||
*/
|
||||
|
||||
import { enqueue } from './queue.js'
|
||||
import { spawnBatchWorker } from './spawn.js'
|
||||
|
||||
let batchWorkerSpawned = false
|
||||
|
||||
function isEnabled(): boolean {
|
||||
if (process.env.CSC_DISABLE_RAW_DUMP === '1' || process.env.CSC_DISABLE_RAW_DUMP === 'true') return false
|
||||
if (process.env.COSTRICT_DISABLE_RAW_DUMP === '1' || process.env.COSTRICT_DISABLE_RAW_DUMP === 'true') return false
|
||||
return true
|
||||
}
|
||||
|
||||
function ensureBatchWorker() {
|
||||
if (batchWorkerSpawned) return
|
||||
batchWorkerSpawned = true
|
||||
spawnBatchWorker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报一轮对话
|
||||
* 只写入队列,由 batch worker 顺序消费
|
||||
*/
|
||||
export function reportTurn(sessionID: string, messageID: string, directory: string): void {
|
||||
if (!isEnabled()) return
|
||||
enqueue({ sessionID, messageID, directory })
|
||||
ensureBatchWorker()
|
||||
}
|
||||
|
||||
export function reportSession(sessionID: string, directory: string): void {
|
||||
if (!isEnabled()) return
|
||||
enqueue({ sessionID, messageID: '__summary__', directory })
|
||||
ensureBatchWorker()
|
||||
}
|
||||
39
src/services/rawDump/logger.ts
Normal file
39
src/services/rawDump/logger.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Raw Dump 日志模块
|
||||
* 通过环境变量开关控制,默认关闭,与业务逻辑完全解耦
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const LOG_FILE = path.join(os.homedir(), '.claude', 'csc-raw-dump.log')
|
||||
|
||||
function isDebugEnabled(): boolean {
|
||||
const v = process.env.CSC_RAW_DUMP_DEBUG
|
||||
return v === '1' || v === 'true'
|
||||
}
|
||||
|
||||
export function createLogger(prefix: string) {
|
||||
const enabled = isDebugEnabled()
|
||||
|
||||
function write(level: string, msg: string, meta?: Record<string, unknown>) {
|
||||
if (!enabled) return
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const line = `[${timestamp}] [${prefix}:${level}] ${msg}${metaStr}\n`
|
||||
console.error(line.trimEnd())
|
||||
try {
|
||||
appendFileSync(LOG_FILE, line)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
debug: (msg: string, meta?: Record<string, unknown>) => write('debug', msg, meta),
|
||||
info: (msg: string, meta?: Record<string, unknown>) => write('info', msg, meta),
|
||||
warn: (msg: string, meta?: Record<string, unknown>) => write('warn', msg, meta),
|
||||
error: (msg: string, meta?: Record<string, unknown>) => write('error', msg, meta),
|
||||
}
|
||||
}
|
||||
87
src/services/rawDump/queue.ts
Normal file
87
src/services/rawDump/queue.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Raw Dump 任务队列
|
||||
* 主进程只写队列,独立 batch worker 顺序消费
|
||||
*/
|
||||
|
||||
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const QUEUE_FILE = path.join(os.homedir(), '.claude', 'csc-raw-dump-queue.jsonl')
|
||||
const LOCK_FILE = path.join(os.homedir(), '.claude', 'csc-raw-dump.lock')
|
||||
|
||||
export interface QueueTask {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
directory: string
|
||||
enqueuedAt: number
|
||||
}
|
||||
|
||||
export function enqueue(task: Omit<QueueTask, 'enqueuedAt'>): void {
|
||||
const item: QueueTask = { ...task, enqueuedAt: Date.now() }
|
||||
try {
|
||||
appendFileSync(QUEUE_FILE, JSON.stringify(item) + '\n', 'utf-8')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function readQueue(): QueueTask[] {
|
||||
try {
|
||||
const text = readFileSync(QUEUE_FILE, 'utf-8')
|
||||
return text
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as QueueTask
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((t): t is QueueTask => t !== null)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function clearQueue(): void {
|
||||
try {
|
||||
writeFileSync(QUEUE_FILE, '', 'utf-8')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireLock(): boolean {
|
||||
try {
|
||||
// 简单文件锁:如果 lock 文件存在且 60 秒内,认为已有 worker
|
||||
try {
|
||||
const stat = readFileSync(LOCK_FILE, 'utf-8')
|
||||
const pid = parseInt(stat, 10)
|
||||
if (!isNaN(pid) && pid !== process.pid) {
|
||||
// 检查进程是否还在运行
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return false // 已有 worker 在运行
|
||||
} catch {
|
||||
// 进程已退出,可以抢占锁
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// lock 文件不存在
|
||||
}
|
||||
writeFileSync(LOCK_FILE, String(process.pid), 'utf-8')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseLock(): void {
|
||||
try {
|
||||
writeFileSync(LOCK_FILE, '', 'utf-8')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
32
src/services/rawDump/spawn.ts
Normal file
32
src/services/rawDump/spawn.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Raw Dump Worker 进程启动器
|
||||
* 启动独立的 batch worker 顺序消费队列
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export function spawnBatchWorker(): void {
|
||||
const entry = process.execPath
|
||||
const isDev = path.basename(entry).toLowerCase().startsWith('bun')
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workerPath = path.resolve(__dirname, 'batchWorker.ts')
|
||||
|
||||
const args = isDev
|
||||
? ['run', workerPath]
|
||||
: [workerPath]
|
||||
|
||||
const child = spawn(entry, args, {
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error('[raw-dump] batch worker spawn error:', err.message)
|
||||
})
|
||||
|
||||
child.unref()
|
||||
}
|
||||
37
src/services/rawDump/state.ts
Normal file
37
src/services/rawDump/state.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Raw Dump 磁盘状态管理
|
||||
* 用于 conversation 和 commits 的去重
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { RawDumpState } from './types.js'
|
||||
|
||||
const STATE_DIR = path.join(os.homedir(), '.claude')
|
||||
const STATE_FILE = path.join(STATE_DIR, 'csc-raw-dump-state.json')
|
||||
|
||||
function createEmptyState(): RawDumpState {
|
||||
return {
|
||||
conversation: {},
|
||||
commits: {},
|
||||
}
|
||||
}
|
||||
|
||||
export async function readState(): Promise<RawDumpState> {
|
||||
try {
|
||||
const text = await fs.readFile(STATE_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(text) as Partial<RawDumpState>
|
||||
return {
|
||||
conversation: parsed.conversation ?? {},
|
||||
commits: parsed.commits ?? {},
|
||||
}
|
||||
} catch {
|
||||
return createEmptyState()
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeState(state: RawDumpState): Promise<void> {
|
||||
await fs.mkdir(STATE_DIR, { recursive: true })
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8')
|
||||
}
|
||||
95
src/services/rawDump/types.ts
Normal file
95
src/services/rawDump/types.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Raw Dump 上报类型定义
|
||||
* 与框架解耦,不依赖任何 UI 或特定运行时
|
||||
*/
|
||||
|
||||
export const RAW_DUMP_EVENT_ENV_KEY = '__CSC_RAW_DUMP_EVENT__'
|
||||
|
||||
export interface RawDumpEventPayload {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export interface RawDumpState {
|
||||
conversation: Record<string, true>
|
||||
commits: Record<string, string>
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub?: string
|
||||
name?: string
|
||||
id?: string
|
||||
universal_id?: string
|
||||
displayName?: string
|
||||
properties?: {
|
||||
oauth_GitHub_username?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConversationPayload {
|
||||
task_id: string
|
||||
request_id: string
|
||||
prompt_mode: string
|
||||
mode: string
|
||||
model: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
process_time: number
|
||||
process_ttft: number
|
||||
upstream_tokens: number
|
||||
downstream_tokens: number
|
||||
cost: number
|
||||
sender: string
|
||||
request_content: string
|
||||
response_content: string
|
||||
user_input: string
|
||||
diff: string
|
||||
diff_lines: number
|
||||
files: string[]
|
||||
error_code?: number
|
||||
error_reason?: string
|
||||
}
|
||||
|
||||
export interface SummaryPayload {
|
||||
task_id: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
client_id: string
|
||||
client_ide: string
|
||||
client_version: string
|
||||
client_os: string
|
||||
client_os_version: string
|
||||
caller: string
|
||||
repo_addr: string
|
||||
repo_branch: string
|
||||
work_dir: string
|
||||
upstream_tokens: number
|
||||
downstream_tokens: number
|
||||
cost: number
|
||||
diff: string
|
||||
diff_lines: number
|
||||
files: string[]
|
||||
}
|
||||
|
||||
export interface CommitPayload {
|
||||
commit_id: string
|
||||
commit_time: string
|
||||
repo_addr: string
|
||||
repo_branch: string
|
||||
git_user_name: string
|
||||
git_user_email: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
client_id: string
|
||||
client_version: string
|
||||
client_ide: string
|
||||
work_dir: string
|
||||
diff_lines: number
|
||||
diff: string
|
||||
files: string[]
|
||||
comment: string
|
||||
subject: string
|
||||
}
|
||||
655
src/services/rawDump/worker.ts
Normal file
655
src/services/rawDump/worker.ts
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
/**
|
||||
* Raw Dump Worker
|
||||
* 独立进程,通过环境变量接收任务,执行实际上报逻辑
|
||||
* 与主进程/框架完全解耦
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
loadCoStrictCredentials,
|
||||
saveCoStrictCredentials,
|
||||
} from '../../costrict/provider/credentials.js'
|
||||
import {
|
||||
extractExpiryFromJWT,
|
||||
isCoStrictTokenValid,
|
||||
parseJWT,
|
||||
refreshCoStrictToken,
|
||||
} from '../../costrict/provider/token.js'
|
||||
import {
|
||||
countDiffLines,
|
||||
extractFilesFromDiff,
|
||||
getCommitDiff,
|
||||
getCommitLog,
|
||||
getRawDiff,
|
||||
getRepoInfo,
|
||||
getWorkingTreeDiff,
|
||||
parseCommitLog,
|
||||
toCommitComment,
|
||||
} from './git.js'
|
||||
import { createLogger } from './logger.js'
|
||||
import { readState, writeState } from './state.js'
|
||||
import { RAW_DUMP_EVENT_ENV_KEY, type RawDumpEventPayload } from './types.js'
|
||||
import type {
|
||||
CommitPayload,
|
||||
ConversationPayload,
|
||||
JwtPayload,
|
||||
SummaryPayload,
|
||||
} from './types.js'
|
||||
|
||||
const log = createLogger('raw-dump')
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000 // 单次 HTTP 请求超时,防止 fetch 永久挂起
|
||||
|
||||
type RepoInfo = Awaited<ReturnType<typeof getRepoInfo>>
|
||||
|
||||
function formatIso(ms: number | undefined): string {
|
||||
if (!ms) return ''
|
||||
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
function resolveRawDumpBaseUrl(baseUrl?: string): string {
|
||||
const explicit = process.env.COSTRICT_RAW_DUMP_BASE_URL || process.env.CSC_RAW_DUMP_BASE_URL
|
||||
if (explicit) return explicit.replace(/\/$/, '')
|
||||
|
||||
const raw = (baseUrl || process.env.COSTRICT_BASE_URL || 'https://zgsm.sangfor.com').replace(/\/$/, '')
|
||||
if (raw.includes('/chat-rag/api/forward')) {
|
||||
try {
|
||||
const url = new URL(raw)
|
||||
const target = url.searchParams.get('target')
|
||||
if (target) return new URL(target).origin
|
||||
return url.origin
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return raw.replace(/\/cloud-api$/, '')
|
||||
}
|
||||
|
||||
function getRawDumpUrl(baseUrl: string, endpoint: string): string {
|
||||
const suffix = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
|
||||
return `${baseUrl}/user-indicator/api/v1${suffix}`
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
baseUrl: string,
|
||||
headers: Headers,
|
||||
endpoint: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const url = getRawDumpUrl(baseUrl, endpoint)
|
||||
log('debug', `POST ${endpoint}`, { url })
|
||||
|
||||
let lastError: Error | undefined
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (attempt > 0) {
|
||||
const delay = 5000 * Math.pow(2, attempt - 1) // 5s, 10s
|
||||
log('debug', `retrying ${endpoint} after ${delay}ms`, { attempt })
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
log('debug', `POST ${endpoint} ok`, { status: res.status })
|
||||
return
|
||||
}
|
||||
|
||||
const text = await res.text().catch(() => '')
|
||||
// 429 限流时重试,其他错误直接抛
|
||||
if (res.status === 429) {
|
||||
log('warn', `${endpoint} got 429, will retry`, { attempt, text: text.slice(0, 200) })
|
||||
lastError = new Error(`${endpoint} failed: ${res.status} ${text}`)
|
||||
continue
|
||||
}
|
||||
throw new Error(`${endpoint} failed: ${res.status} ${text}`)
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err))
|
||||
const isAbort = lastError.name === 'AbortError'
|
||||
// 网络错误 / 超时也重试
|
||||
log('warn', `${endpoint} ${isAbort ? 'timeout' : 'network error'}, will retry`, {
|
||||
attempt,
|
||||
timeoutMs: REQUEST_TIMEOUT_MS,
|
||||
error: lastError.message,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`${endpoint} failed after retries`)
|
||||
}
|
||||
|
||||
function parseUser(accessPayload: JwtPayload, refreshPayload?: JwtPayload | null) {
|
||||
if (refreshPayload) {
|
||||
return {
|
||||
user_id: refreshPayload.universal_id ?? refreshPayload.sub ?? refreshPayload.id ?? '',
|
||||
user_name: refreshPayload.properties?.oauth_GitHub_username || refreshPayload.id || '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
user_id: accessPayload.universal_id ?? accessPayload.sub ?? accessPayload.id ?? '',
|
||||
user_name: accessPayload.displayName ?? accessPayload.name ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function detectOs(): string {
|
||||
const map: Record<string, string> = { darwin: 'MacOS', win32: 'Windows', linux: 'Linux' }
|
||||
return map[process.platform] ?? process.platform
|
||||
}
|
||||
|
||||
export async function auth() {
|
||||
log('debug', 'auth start')
|
||||
let creds = await loadCoStrictCredentials()
|
||||
if (!creds?.access_token) throw new Error('Not authenticated')
|
||||
log('debug', 'credentials loaded', { hasRefreshToken: !!creds.refresh_token, baseUrl: creds.base_url })
|
||||
|
||||
// Token 刷新
|
||||
if (creds.refresh_token && !isCoStrictTokenValid(creds)) {
|
||||
log('debug', 'token expired, refreshing...')
|
||||
const next = await refreshCoStrictToken({
|
||||
baseUrl: creds.base_url,
|
||||
refreshToken: creds.refresh_token,
|
||||
state: creds.state,
|
||||
})
|
||||
await saveCoStrictCredentials({
|
||||
...creds,
|
||||
access_token: next.access_token,
|
||||
refresh_token: next.refresh_token,
|
||||
expiry_date: extractExpiryFromJWT(next.access_token),
|
||||
updated_at: new Date().toISOString(),
|
||||
expired_at: new Date(extractExpiryFromJWT(next.access_token)).toISOString(),
|
||||
})
|
||||
creds = { ...creds, access_token: next.access_token, refresh_token: next.refresh_token }
|
||||
log('debug', 'token refreshed')
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.set('Authorization', `Bearer ${creds.access_token}`)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
headers.set('HTTP-Referer', 'https://github.com/zgsm-ai/costrict-cli')
|
||||
headers.set('X-Title', 'CoStrict-CLI')
|
||||
|
||||
// 尝试读取版本信息(从 package.json)
|
||||
let version = 'unknown'
|
||||
try {
|
||||
const pkgPath = path.resolve(fileURLToPath(import.meta.url), '../../../../package.json')
|
||||
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))
|
||||
version = pkg.version ?? 'unknown'
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
headers.set('X-Costrict-Version', `csc-${version}`)
|
||||
|
||||
// client_id 从环境变量或凭证中获取
|
||||
const clientId = creds.machine_id || process.env.CSC_MACHINE_ID || 'unknown'
|
||||
headers.set('zgsm-client-id', clientId)
|
||||
headers.set('zgsm-client-ide', 'cli')
|
||||
|
||||
const accessPayload = parseJWT(creds.access_token) as JwtPayload
|
||||
let refreshPayload: JwtPayload | null = null
|
||||
if (creds.refresh_token) {
|
||||
try {
|
||||
refreshPayload = parseJWT(creds.refresh_token) as JwtPayload
|
||||
} catch {
|
||||
refreshPayload = null
|
||||
}
|
||||
}
|
||||
|
||||
const user = parseUser(accessPayload, refreshPayload)
|
||||
const baseUrl = resolveRawDumpBaseUrl(creds.base_url)
|
||||
log('debug', 'auth success', { baseUrl, user_id: user.user_id, clientId, version })
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
headers,
|
||||
user,
|
||||
clientId,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
// 从 JSONL 文件加载会话消息
|
||||
// csc 的会话文件名可能是 ses_{hash}.jsonl 或 {uuid}.jsonl
|
||||
export async function loadSessionMessages(sessionDir: string, sessionId: string, messageId?: string) {
|
||||
try {
|
||||
const entries = await fs.readdir(sessionDir)
|
||||
const jsonlFiles = entries.filter((f) => f.endsWith('.jsonl'))
|
||||
log('debug', 'found jsonl files', { sessionDir, count: jsonlFiles.length, files: jsonlFiles.slice(0, 5) })
|
||||
|
||||
for (const file of jsonlFiles) {
|
||||
const filePath = path.join(sessionDir, file)
|
||||
try {
|
||||
const text = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((m): m is Record<string, unknown> => m !== null)
|
||||
|
||||
// 检查是否包含目标 sessionId 或 messageId
|
||||
const hasSession = lines.some(
|
||||
(m) => m.sessionId === sessionId || m.session_id === sessionId || m.uuid === sessionId,
|
||||
)
|
||||
const hasMessage = messageId ? lines.some((m) => m.uuid === messageId || (m.message as Record<string, unknown>)?.id === messageId) : false
|
||||
if (hasSession || hasMessage) {
|
||||
log('debug', 'loaded messages from file', { file, count: lines.length, hasSession, hasMessage })
|
||||
return lines
|
||||
}
|
||||
} catch {
|
||||
// ignore per-file errors
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore dir read errors
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function findMessage(
|
||||
messages: Record<string, unknown>[],
|
||||
messageID: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
return messages.find((m) => m.uuid === messageID || (m.message as Record<string, unknown>)?.id === messageID)
|
||||
}
|
||||
|
||||
function findParentUserMessage(
|
||||
messages: Record<string, unknown>[],
|
||||
assistantMsg: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
// 在 csc 中,user message 通常在 assistant message 之前
|
||||
const assistantIndex = messages.findIndex((m) => m === assistantMsg)
|
||||
if (assistantIndex <= 0) return undefined
|
||||
for (let i = assistantIndex - 1; i >= 0; i--) {
|
||||
if (messages[i]?.type === 'user') return messages[i]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractTextContent(msg: Record<string, unknown>): string {
|
||||
const content = (msg.message as Record<string, unknown>)?.content
|
||||
if (!Array.isArray(content)) return String(content ?? '')
|
||||
return content
|
||||
.filter((block): block is Record<string, unknown> => block?.type === 'text')
|
||||
.map((block) => String(block.text ?? ''))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function extractToolDiff(msg: Record<string, unknown>): { diff: string; diff_lines: number; files: string[] } {
|
||||
const content = (msg.message as Record<string, unknown>)?.content
|
||||
if (!Array.isArray(content)) return { diff: '', diff_lines: 0, files: [] }
|
||||
|
||||
const diffs: string[] = []
|
||||
const files = new Set<string>()
|
||||
|
||||
for (const block of content) {
|
||||
if (block?.type === 'tool_use') {
|
||||
const input = block.input as Record<string, unknown> | undefined
|
||||
if (typeof input?.content === 'string' && input.content) diffs.push(input.content)
|
||||
else if (typeof input?.new_string === 'string' && input.new_string) diffs.push(input.new_string)
|
||||
else if (typeof input?.diff === 'string' && input.diff) diffs.push(input.diff)
|
||||
else if (typeof input?.patch === 'string' && input.patch) diffs.push(input.patch)
|
||||
}
|
||||
if (block?.type === 'tool_result') {
|
||||
const content = block.content as string | undefined
|
||||
if (typeof content === 'string' && content) diffs.push(content)
|
||||
}
|
||||
}
|
||||
|
||||
const diff = diffs.join('\n')
|
||||
for (const file of extractFilesFromDiff(diff)) files.add(file)
|
||||
return { diff, diff_lines: countDiffLines(diff), files: Array.from(files) }
|
||||
}
|
||||
|
||||
function extractUsage(msg: Record<string, unknown>) {
|
||||
const usage = (msg.message as Record<string, unknown>)?.usage as Record<string, number> | undefined
|
||||
return {
|
||||
input_tokens: usage?.input_tokens ?? 0,
|
||||
output_tokens: usage?.output_tokens ?? 0,
|
||||
cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0,
|
||||
cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function extractError(msg: Record<string, unknown>) {
|
||||
const error = msg.error as Record<string, unknown> | undefined
|
||||
if (!error) return {}
|
||||
|
||||
const name = String(error.name ?? 'UnknownError')
|
||||
const message = typeof error.message === 'string' ? error.message : name
|
||||
const errorCode =
|
||||
name === 'ProviderAuthError'
|
||||
? 401
|
||||
: name === 'ContextOverflowError' || name === 'MessageOutputLengthError'
|
||||
? 413
|
||||
: name === 'MessageAbortedError'
|
||||
? 499
|
||||
: name === 'APIError' && typeof error.statusCode === 'number'
|
||||
? error.statusCode
|
||||
: 500
|
||||
|
||||
return { error_code: errorCode, error_reason: message }
|
||||
}
|
||||
|
||||
export async function uploadConversation(
|
||||
payload: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
directory: string
|
||||
messages: Record<string, unknown>[]
|
||||
},
|
||||
authData: Awaited<ReturnType<typeof auth>>,
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
options?: { workingTreeDiff?: string },
|
||||
): Promise<boolean> {
|
||||
log('debug', 'uploadConversation start', { messageID: payload.messageID, messageCount: payload.messages.length })
|
||||
|
||||
let assistant = findMessage(payload.messages, payload.messageID)
|
||||
if (!assistant || assistant.type !== 'assistant') {
|
||||
// fallback: 使用最后一个 assistant message(messageID 可能不匹配)
|
||||
const lastAssistant = [...payload.messages].reverse().find((m) => m.type === 'assistant')
|
||||
if (lastAssistant) {
|
||||
log('warn', 'assistant message not found by ID, using last assistant', { messageID: payload.messageID, fallbackUuid: lastAssistant.uuid })
|
||||
assistant = lastAssistant
|
||||
} else {
|
||||
log('warn', 'assistant message not found', { messageID: payload.messageID, foundType: assistant?.type })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const requestID = ((assistant.message as Record<string, unknown>)?.id as string) || String(assistant.uuid) || payload.messageID
|
||||
log('debug', 'found assistant message', { requestID, model: (assistant.message as Record<string, unknown>)?.model, uuid: assistant.uuid })
|
||||
|
||||
const key = `${payload.sessionID}:${requestID}`
|
||||
if (state.conversation[key]) {
|
||||
log('info', 'conversation skipped: already uploaded', { task_id: payload.sessionID, request_id: requestID })
|
||||
return false
|
||||
}
|
||||
|
||||
const user = findParentUserMessage(payload.messages, assistant)
|
||||
log('debug', 'found parent user message', { hasUser: !!user, userTimestamp: user?.timestamp })
|
||||
|
||||
const userMsgTime = (user?.timestamp as number) || Date.now()
|
||||
const assistantMsgTime = (assistant.timestamp as number) || Date.now()
|
||||
|
||||
// diff: 优先从 tool_use 提取,fallback 到 git diff HEAD(可由上层预加载传入)
|
||||
const toolDiff = extractToolDiff(assistant)
|
||||
log('debug', 'extracted tool diff', { toolDiffLength: toolDiff.diff.length, toolDiffLines: toolDiff.diff_lines, toolDiffFiles: toolDiff.files.length })
|
||||
|
||||
const rawDiff = toolDiff.diff || options?.workingTreeDiff || (await getWorkingTreeDiff(payload.directory))
|
||||
log('debug', 'final diff', { diffLength: rawDiff.length, hasToolDiff: !!toolDiff.diff, fromCache: !toolDiff.diff && !!options?.workingTreeDiff })
|
||||
|
||||
const diffLines = rawDiff ? countDiffLines(rawDiff) : 0
|
||||
const files = rawDiff ? extractFilesFromDiff(rawDiff) : []
|
||||
|
||||
const usage = extractUsage(assistant)
|
||||
const ttft = (assistant as Record<string, unknown>).ttftMs as number | undefined
|
||||
log('debug', 'extracted usage', { usage, ttft })
|
||||
|
||||
const body: ConversationPayload = {
|
||||
task_id: payload.sessionID,
|
||||
request_id: requestID,
|
||||
prompt_mode: (user?.variant as string) || '',
|
||||
mode: (assistant.mode as string) || (assistant.agent as string) || 'code',
|
||||
model: ((assistant.message as Record<string, unknown>)?.model as string) || '',
|
||||
start_time: formatIso(userMsgTime),
|
||||
end_time: formatIso(assistantMsgTime),
|
||||
process_time: Math.max(0, assistantMsgTime - userMsgTime),
|
||||
process_ttft: ttft ?? 0,
|
||||
upstream_tokens: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
|
||||
downstream_tokens: usage.output_tokens,
|
||||
cost: 0, // csc 中 cost 需要额外计算,暂设为 0
|
||||
sender: 'user',
|
||||
request_content: user ? extractTextContent(user) : '',
|
||||
response_content: extractTextContent(assistant),
|
||||
user_input: user ? extractTextContent(user) : '',
|
||||
diff: rawDiff,
|
||||
diff_lines: diffLines,
|
||||
files,
|
||||
...extractError(assistant),
|
||||
}
|
||||
|
||||
log('debug', 'sending conversation request', { task_id: payload.sessionID, request_id: requestID, bodyKeys: Object.keys(body) })
|
||||
await postJson(authData.baseUrl, authData.headers, '/raw-store/task-conversation', body)
|
||||
state.conversation[key] = true
|
||||
log('info', 'conversation uploaded', { task_id: payload.sessionID, request_id: requestID, upstream_tokens: body.upstream_tokens, downstream_tokens: body.downstream_tokens })
|
||||
return true
|
||||
}
|
||||
|
||||
export async function uploadSummary(
|
||||
payload: {
|
||||
sessionID: string
|
||||
directory: string
|
||||
messages: Record<string, unknown>[]
|
||||
},
|
||||
authData: Awaited<ReturnType<typeof auth>>,
|
||||
options?: { repoInfo?: RepoInfo; workingTreeDiff?: string },
|
||||
): Promise<void> {
|
||||
log('debug', 'uploadSummary start', { sessionID: payload.sessionID, messageCount: payload.messages.length })
|
||||
const repoInfo = options?.repoInfo ?? (await getRepoInfo(payload.directory))
|
||||
const rawDiff = options?.workingTreeDiff ?? (await getWorkingTreeDiff(payload.directory))
|
||||
log('debug', 'summary repo info', {
|
||||
repo_addr: repoInfo.repo_addr,
|
||||
repo_branch: repoInfo.repo_branch,
|
||||
diffLength: rawDiff.length,
|
||||
fromCache: { repo: !!options?.repoInfo, diff: !!options?.workingTreeDiff },
|
||||
})
|
||||
|
||||
const assistants = payload.messages.filter((m) => m.type === 'assistant')
|
||||
const { upstream_tokens, downstream_tokens } = assistants.reduce(
|
||||
(acc, m) => {
|
||||
const usage = extractUsage(m)
|
||||
acc.upstream_tokens += usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
|
||||
acc.downstream_tokens += usage.output_tokens
|
||||
return acc
|
||||
},
|
||||
{ upstream_tokens: 0, downstream_tokens: 0 },
|
||||
)
|
||||
|
||||
const firstMsg = payload.messages[0]
|
||||
const lastMsg = payload.messages[payload.messages.length - 1]
|
||||
|
||||
const body: SummaryPayload = {
|
||||
task_id: payload.sessionID,
|
||||
start_time: formatIso((firstMsg?.timestamp as number) || Date.now()),
|
||||
end_time: formatIso((lastMsg?.timestamp as number) || Date.now()),
|
||||
...authData.user,
|
||||
client_id: authData.clientId,
|
||||
client_ide: 'cli',
|
||||
client_version: authData.version,
|
||||
client_os: detectOs(),
|
||||
client_os_version: os.release(),
|
||||
caller: 'chat',
|
||||
repo_addr: repoInfo.repo_addr,
|
||||
repo_branch: repoInfo.repo_branch,
|
||||
work_dir: payload.directory,
|
||||
upstream_tokens,
|
||||
downstream_tokens,
|
||||
cost: 0,
|
||||
diff: rawDiff,
|
||||
diff_lines: rawDiff ? countDiffLines(rawDiff) : 0,
|
||||
files: rawDiff ? extractFilesFromDiff(rawDiff) : [],
|
||||
}
|
||||
|
||||
await postJson(authData.baseUrl, authData.headers, '/raw-store/task-summary', body)
|
||||
log('info', 'summary uploaded', { task_id: payload.sessionID, upstream_tokens: body.upstream_tokens, downstream_tokens: body.downstream_tokens, diff_lines: body.diff_lines })
|
||||
}
|
||||
|
||||
export async function uploadCommits(
|
||||
payload: {
|
||||
directory: string
|
||||
},
|
||||
authData: Awaited<ReturnType<typeof auth>>,
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
options?: { repoInfo?: RepoInfo },
|
||||
): Promise<number> {
|
||||
log('debug', 'uploadCommits start', { directory: payload.directory })
|
||||
const repoInfo = options?.repoInfo ?? (await getRepoInfo(payload.directory))
|
||||
if (!repoInfo.repo_addr || !repoInfo.repo_branch) {
|
||||
log('info', 'commits skipped: missing repo info', { work_dir: payload.directory, repo_addr: repoInfo.repo_addr, repo_branch: repoInfo.repo_branch })
|
||||
return 0
|
||||
}
|
||||
|
||||
const stateKey = `${repoInfo.repo_addr}#${repoInfo.repo_branch}#${payload.directory}`
|
||||
const lastCommit = state.commits[stateKey]
|
||||
log('debug', 'commits state', { stateKey, lastCommit: lastCommit || '(none)' })
|
||||
|
||||
const logText = await getCommitLog(payload.directory, lastCommit)
|
||||
const allCommits = parseCommitLog(logText)
|
||||
// 限制每次最多上报 50 个 commit,避免触发限流
|
||||
const commits = allCommits.slice(0, 50)
|
||||
log('debug', 'parsed commits', { total: allCommits.length, sending: commits.length })
|
||||
|
||||
if (!commits.length) {
|
||||
log('info', 'commits skipped: no new commits', { work_dir: payload.directory })
|
||||
return 0
|
||||
}
|
||||
|
||||
for (let i = 0; i < commits.length; i++) {
|
||||
const commit = commits[i]
|
||||
// 批次间添加小延迟,避免并发过高
|
||||
if (i > 0 && i % 10 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
const diff = await getCommitDiff(payload.directory, commit.commit_id)
|
||||
const body: CommitPayload = {
|
||||
commit_id: commit.commit_id,
|
||||
commit_time: commit.commit_time,
|
||||
repo_addr: repoInfo.repo_addr,
|
||||
repo_branch: repoInfo.repo_branch,
|
||||
git_user_name: commit.git_user_name,
|
||||
git_user_email: commit.git_user_email,
|
||||
...authData.user,
|
||||
client_id: authData.clientId,
|
||||
client_version: authData.version,
|
||||
client_ide: 'cli',
|
||||
work_dir: payload.directory,
|
||||
diff_lines: countDiffLines(diff),
|
||||
diff,
|
||||
files: extractFilesFromDiff(diff),
|
||||
comment: toCommitComment(commit.subject),
|
||||
subject: commit.subject,
|
||||
}
|
||||
await postJson(authData.baseUrl, authData.headers, '/raw-store/commit', body)
|
||||
// 每成功一个 commit 立即更新 state,避免失败后全部重传
|
||||
state.commits[stateKey] = commit.commit_id
|
||||
log('info', 'commit uploaded', { commit_id: commit.commit_id, progress: `${i + 1}/${commits.length}` })
|
||||
}
|
||||
|
||||
return commits.length
|
||||
}
|
||||
|
||||
function parseWorkerPayload(): RawDumpEventPayload {
|
||||
const raw = process.env[RAW_DUMP_EVENT_ENV_KEY]
|
||||
if (!raw) throw new Error('missing raw dump payload')
|
||||
return JSON.parse(raw) as RawDumpEventPayload
|
||||
}
|
||||
|
||||
export function getClaudeConfigHomeDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_HOME || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
function normalizeProjectPath(dir: string): string {
|
||||
// 将 /Users/linkai/code/csc 转换为 -Users-linkai-code-csc
|
||||
return dir.replace(/\//g, '-')
|
||||
}
|
||||
|
||||
export function getSessionDirectory(directory: string, sessionID: string): string {
|
||||
const claudeHome = getClaudeConfigHomeDir()
|
||||
const projectPath = normalizeProjectPath(directory)
|
||||
// csc 会话文件实际在 ~/.claude/projects/{project-path}/
|
||||
const candidates = [
|
||||
path.join(claudeHome, 'projects', projectPath),
|
||||
path.join(claudeHome, 'transcripts'),
|
||||
path.join(claudeHome, 'sessions'),
|
||||
path.join(directory, '.claude', 'sessions'),
|
||||
path.join(directory, '.claude'),
|
||||
directory,
|
||||
process.env.CSC_SESSION_DIR || '',
|
||||
]
|
||||
return candidates.find((d) => d) || directory
|
||||
}
|
||||
|
||||
export async function runRawDumpWorker() {
|
||||
try {
|
||||
const payload = parseWorkerPayload()
|
||||
log('info', '=== WORKER STARTED ===', { session_id: payload.sessionID, message_id: payload.messageID, directory: payload.directory })
|
||||
|
||||
const sessionDir = getSessionDirectory(payload.directory, payload.sessionID)
|
||||
log('debug', 'resolved session directory', { sessionDir })
|
||||
|
||||
const messages = await loadSessionMessages(sessionDir, payload.sessionID, payload.messageID)
|
||||
log('info', 'session loaded', { session_id: payload.sessionID, message_count: messages.length, directory: sessionDir })
|
||||
|
||||
if (messages.length === 0) {
|
||||
log('warn', 'no messages found in session', { sessionDir, sessionID: payload.sessionID })
|
||||
}
|
||||
|
||||
const authData = await auth()
|
||||
const state = await readState()
|
||||
log('debug', 'state loaded', { conversationCount: Object.keys(state.conversation).length, commitCount: Object.keys(state.commits).length })
|
||||
|
||||
// 预加载 git 信息,三次上传共享,避免重复 spawn git
|
||||
const repoInfo = await getRepoInfo(payload.directory)
|
||||
const workingTreeDiff = await getWorkingTreeDiff(payload.directory)
|
||||
log('debug', 'preloaded git info', { repo_branch: repoInfo.repo_branch, diffLength: workingTreeDiff.length })
|
||||
|
||||
log('debug', 'starting uploadConversation...')
|
||||
const conversationUploaded = await uploadConversation(
|
||||
{ ...payload, messages },
|
||||
authData,
|
||||
state,
|
||||
{ workingTreeDiff },
|
||||
)
|
||||
log('debug', 'uploadConversation done', { conversationUploaded })
|
||||
|
||||
log('debug', 'starting uploadSummary...')
|
||||
await uploadSummary(
|
||||
{ sessionID: payload.sessionID, directory: payload.directory, messages },
|
||||
authData,
|
||||
{ repoInfo, workingTreeDiff },
|
||||
)
|
||||
log('debug', 'uploadSummary done')
|
||||
|
||||
log('debug', 'starting uploadCommits...')
|
||||
const commitCount = await uploadCommits({ directory: payload.directory }, authData, state, { repoInfo })
|
||||
log('debug', 'uploadCommits done', { commitCount })
|
||||
|
||||
await writeState(state)
|
||||
log('debug', 'state saved')
|
||||
|
||||
log('info', '=== WORKER COMPLETED ===', {
|
||||
session_id: payload.sessionID,
|
||||
message_id: payload.messageID,
|
||||
conversation_uploaded: conversationUploaded,
|
||||
commits_uploaded: commitCount,
|
||||
})
|
||||
} catch (error) {
|
||||
log('error', '=== WORKER FAILED ===', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此文件(作为 worker 进程入口)
|
||||
if (process.argv[1]?.includes('worker')) {
|
||||
runRawDumpWorker()
|
||||
}
|
||||
|
|
@ -205,6 +205,10 @@ export type GlobalConfig = {
|
|||
// @deprecated - Migrated to ~/.claude/cache/changelog.md. Keep for migration support.
|
||||
cachedChangelog?: string
|
||||
mcpServers?: Record<string, McpServerConfig>
|
||||
// Favorite agents loaded from CoStrict cloud (config layer, runtime integration TBD)
|
||||
agents?: Record<string, Record<string, unknown> & { prompt: string }>
|
||||
// Favorite commands loaded from CoStrict cloud (config layer, runtime integration TBD)
|
||||
commands?: Record<string, Record<string, unknown> & { template: string }>
|
||||
// claude.ai MCP connectors that have successfully connected at least once.
|
||||
// Used to gate "connector unavailable" / "needs auth" startup notifications:
|
||||
// a connector the user has actually used is worth flagging when it breaks,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from '../../services/mcp/mcpStringUtils.js'
|
||||
import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js'
|
||||
import { AGENT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AgentTool/constants.js'
|
||||
import { ASK_USER_QUESTION_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/prompt.js'
|
||||
import { shouldUseSandbox } from '@claude-code-best/builtin-tools/tools/BashTool/shouldUseSandbox.js'
|
||||
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
|
||||
import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/PowerShellTool/toolName.js'
|
||||
|
|
@ -90,6 +91,7 @@ import {
|
|||
} from '../messages.js'
|
||||
import { calculateCostFromTokens } from '../modelCost.js'
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import { getInitialSettings } from '../settings/settings.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import {
|
||||
createDenialTrackingState,
|
||||
|
|
@ -1233,8 +1235,12 @@ async function hasPermissionsToUseToolInner(
|
|||
behavior: 'passthrough',
|
||||
message: createPermissionRequestMessage(tool.name),
|
||||
}
|
||||
// parsedInput is hoisted so the 1e-headless path can build updatedInput from
|
||||
// the Zod-transformed value (includes defaults like multiSelect: false) rather
|
||||
// than the raw input.
|
||||
let parsedInput: { [key: string]: unknown } = input
|
||||
try {
|
||||
const parsedInput = tool.inputSchema.parse(input)
|
||||
parsedInput = tool.inputSchema.parse(input)
|
||||
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
|
||||
} catch (e) {
|
||||
// Rethrow abort errors so they propagate properly
|
||||
|
|
@ -1248,12 +1254,87 @@ async function hasPermissionsToUseToolInner(
|
|||
if (toolPermissionResult?.behavior === 'deny') {
|
||||
return toolPermissionResult
|
||||
}
|
||||
|
||||
// 1e. Tool requires user interaction even in bypass mode
|
||||
if (
|
||||
tool.requiresUserInteraction?.() &&
|
||||
toolPermissionResult?.behavior === 'ask'
|
||||
) {
|
||||
// 1e-headless. In headless mode (e.g. -p pipe mode, sub-agents), the TUI component
|
||||
// AskUserQuestionPermissionRequest is never mounted, so its useEffect/setTimeout
|
||||
// timeout logic never fires. We replicate that behaviour here:
|
||||
// - read askUserQuestionTimeoutSeconds from settings (default 600 s)
|
||||
// - wait that long, then auto-select the first option for each question
|
||||
// This respects the user-configured timeout value, not just 0.
|
||||
if (
|
||||
appState.toolPermissionContext.shouldAvoidPermissionPrompts &&
|
||||
tool.name === ASK_USER_QUESTION_TOOL_NAME
|
||||
) {
|
||||
const settings = getInitialSettings()
|
||||
const AUTO_SELECT_TIMEOUT_S = settings.askUserQuestionTimeoutSeconds ?? 600
|
||||
// Use parsedInput (Zod-transformed, includes defaults) for questions extraction
|
||||
// and as the base for updatedInput so call() receives a fully-valid input object.
|
||||
const questionsRaw = parsedInput.questions
|
||||
const baseInput = toolPermissionResult.updatedInput ?? parsedInput
|
||||
|
||||
// Build auto-answers: select first option for each question.
|
||||
// Falls back to empty answers if questions array is missing/malformed,
|
||||
// which is still safe — call() treats answers={} as "no answers yet".
|
||||
const buildAutoAnswers = (): Record<string, string> => {
|
||||
const autoAnswers: Record<string, string> = {}
|
||||
if (!Array.isArray(questionsRaw)) return autoAnswers
|
||||
for (const q of questionsRaw) {
|
||||
if (
|
||||
q &&
|
||||
typeof q === 'object' &&
|
||||
typeof (q as Record<string, unknown>).question === 'string' &&
|
||||
Array.isArray((q as Record<string, unknown>).options) &&
|
||||
((q as Record<string, unknown>).options as unknown[]).length > 0
|
||||
) {
|
||||
const qObj = q as { question: string; options: { label: string }[] }
|
||||
autoAnswers[qObj.question] = qObj.options[0].label
|
||||
}
|
||||
}
|
||||
return autoAnswers
|
||||
}
|
||||
|
||||
if (AUTO_SELECT_TIMEOUT_S === 0) {
|
||||
// Immediately auto-select without waiting
|
||||
const autoAnswers = buildAutoAnswers()
|
||||
logForDebugging(
|
||||
`[AskUserQuestion] headless immediate auto-select (askUserQuestionTimeoutSeconds=0): ${jsonStringify(autoAnswers)}`,
|
||||
)
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { ...baseInput, answers: autoAnswers },
|
||||
decisionReason: {
|
||||
type: 'mode',
|
||||
mode: appState.toolPermissionContext.mode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the configured timeout, then auto-select the first option
|
||||
logForDebugging(
|
||||
`[AskUserQuestion] headless mode: waiting ${AUTO_SELECT_TIMEOUT_S}s before auto-selecting first options`,
|
||||
)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, AUTO_SELECT_TIMEOUT_S * 1000))
|
||||
// Re-check abort after the wait
|
||||
if (context.abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
const autoAnswers = buildAutoAnswers()
|
||||
logForDebugging(
|
||||
`[AskUserQuestion] headless auto-select after ${AUTO_SELECT_TIMEOUT_S}s timeout: ${jsonStringify(autoAnswers)}`,
|
||||
)
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { ...baseInput, answers: autoAnswers },
|
||||
decisionReason: {
|
||||
type: 'mode',
|
||||
mode: appState.toolPermissionContext.mode,
|
||||
},
|
||||
}
|
||||
}
|
||||
return toolPermissionResult
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,48 @@
|
|||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const createSessionTurnUploader: () => void = () => {};
|
||||
/**
|
||||
* Session Turn 数据上报
|
||||
* 在 assistant message 完成后触发 Raw Dump 上报(Conversation + Summary + Commits)
|
||||
* 非阻塞,通过 detached 子进程执行
|
||||
*/
|
||||
|
||||
import { reportTurn } from '../services/rawDump/index.js'
|
||||
import { getSessionProjectDir, getSessionId } from '../bootstrap/state.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
|
||||
/**
|
||||
* 创建 session turn 上报器
|
||||
* main.tsx 在 onTurnComplete 回调中调用返回的函数
|
||||
*/
|
||||
export function createSessionTurnUploader(): (messages: Message[]) => void {
|
||||
return (messages: Message[]) => {
|
||||
const sessionId = getSessionId()
|
||||
if (!sessionId) {
|
||||
console.error('[raw-dump] skip: no sessionId')
|
||||
return
|
||||
}
|
||||
|
||||
// 找到最后一个 assistant message
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.type === 'assistant')
|
||||
if (!lastAssistant) {
|
||||
console.error('[raw-dump] skip: no assistant message in turn')
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = String(lastAssistant.uuid || '')
|
||||
if (!messageId) {
|
||||
console.error('[raw-dump] skip: assistant message has no uuid')
|
||||
return
|
||||
}
|
||||
|
||||
const directory = getSessionProjectDir() || process.cwd()
|
||||
console.error('[raw-dump] trigger reportTurn', { sessionId, messageId, directory })
|
||||
reportTurn(sessionId, messageId, directory)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动上报单个 turn(供外部直接调用)
|
||||
*/
|
||||
export function uploadSessionTurn(sessionId: string, assistantMessageUuid: string): void {
|
||||
const directory = getSessionProjectDir() || process.cwd()
|
||||
reportTurn(sessionId, assistantMessageUuid, directory)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user