mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 16:55:51 +00:00
* fix: 终端内容溢出 viewport 时的重影 bug 主屏幕模式下 frame 持续溢出 viewport 时,cursor-restore LF 把内容滚入 scrollback 导致相对光标追踪漂移,可见区 diff 落到错误行产生重影(重复 banner / 错位)。 扩展 log-update overflow 分支为无条件 fullReset(含 \x1b[3J 清 scrollback), 并将主屏 self-healing 清屏从 ERASE_SCREEN (CSI 2 J) 换成 ERASE_DOWN (CSI J), 避免 xterm.js / VSCode 集成终端的 scrollback 边界副作用。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 3 个孤立诊断脚本 - scripts/verify-autofix-pr.ts: 一次性 autofix-pr 验证脚本,全仓零引用 - scripts/smoke-test-commands.ts: 开发期冒烟测试脚本,无任何 import - scripts/probe-subscription-endpoints.ts: 手动 endpoint 探针,无引用 均不在 package.json scripts、build.ts、vite.config.ts、CI workflows 中。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 self-hosted-runner stub 及其 cli.tsx fast-path - 删除 src/self-hosted-runner/main.ts(自动生成的 Promise.resolve() stub) - 同步移除 src/entrypoints/cli.tsx 中 feature('SELF_HOSTED_RUNNER') 守卫的 fast-path 分支 - 该 flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码 删除 stub 单独会留下未解析的动态 import,必须协同拆除。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 agentSdkTypes 中三个 not-implemented stub 移除 watchScheduledTasks、buildMissedTaskNotification、connectRemoteControl 三个 stub 函数(函数体仅 throw new Error('not implemented')),以及仅被这些 stub 引用的孤儿类型(ScheduledTasksHandle、ConnectRemoteControlOptions、RemoteControlHandle、InboundPrompt 等)。 全仓零外部引用。buildMissedTaskNotification 在 src/utils/cronScheduler.ts 有真实可用实现,未受影响。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 Cursor.ts 中未引用的 kill ring 访问器 - 删除 getKillRingItem、getKillRingSize、clearKillRing、canYankPop(全仓零引用的独立 export) - 移除 VIM_WORD_CHAR_REGEX 的 export 关键字(仍由 isVimWordChar 内部使用,保留常量本体) kill ring 特性本身仍活跃(getLastKill/pushToKillRing/yankPop 在 useSearchInput/useTextInput 使用),仅这几个孤儿 helper 未接入。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 insights.ts 中未引用的导出 - 删除 deduplicateSessionBranches(全仓零调用,含 JSDoc) - 删除 buildExportData(全仓零调用,原 S3 上传路径实际用 HTML 而非 JSON) - InsightsExport 仅移除 export 关键字(保留类型本体,仍作为内部返回类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 autonomyCommandSpec.ts 中未引用的导出 - 删除 AUTONOMY_CLI(CLI 子命令描述对象,零引用;handler 仅用 AUTONOMY_USAGE) - 删除 AUTONOMY_COMMAND_DESCRIPTION(值已在 main.tsx:5181 内联) - ParsedAutonomyCommand 仅移除 export 关键字(保留类型作为 parseAutonomyArgs 返回类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 binaryCheck/claudeAiLimits/codeIndexing 中未引用的导出 - binaryCheck.ts: 删除 clearBinaryCache(零调用,binaryCache 仍由 isBinaryInstalled 使用) - claudeAiLimits.ts: 删除 RATE_LIMIT_DISPLAY_NAMES 常量 + getRateLimitDisplayName(互为唯一消费者) - codeIndexing.ts: 删除 detectCodeIndexingFromMcpTool(同胞 detectCodeIndexingFromCommand/McpServerName 仍活跃) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除多处仅内部使用的 export 关键字 下列符号均仅在本文件内被引用,export 关键字冗余;保留符号本体不动: - internalLogging.ts: getContainerId(line 88 内部调用) - api/errors.ts: isMediaSizeError(line 151 内部调用) - api/withRetry.ts: parseMaxTokensContextOverflowError(line 389/724 内部调用) - statsCache.ts: STATS_CACHE_VERSION(7 处内部使用) - startupProfiler.ts: logStartupPerf(line 128 内部调用) - bashCommandHelpers.ts: CommandIdentityCheckers(3 处内部参数类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 清理注释代码块与 legacy shim 注释代码(已死的、引用不存在符号的注释块): - Onboarding.tsx: 注释化的 preflight if-block(引用不存在的 preflightStep) - ultraplan.tsx: 两处引用不存在符号的注释(ULTRAPLAN_INSTRUCTIONS、getUltraplanModel) - types/hooks.ts: 禁用的 type-fest IsEqual 类型断言块 - types/global.d.ts: 已被真实模块取代的 Ultraplan ambient declares - types/textInputTypes.ts: 注释化的 onMessage interface 成员 legacy shim: - cli/bg.ts: 删除 handleBgFlag 别名 export(同胞 handleBgStart 已被所有调用点使用) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 ccshareResume stub 及 main.tsx 的 ccshare fast-path - 删除 src/utils/ccshareResume.ts(parseCcshareId 恒返回 null、loadCcshare 恒抛错的 stub) - 同步移除 src/main.tsx 中 USER_TYPE === 'ant' 守卫下的 if (ccshareId) {...} else {...} 双分支 - 提升 else 块(文件路径 resume 处理)为直接进入 if (options.resume) 块内 ccshare 是 Anthropic 内部特性(go/ccshare URL),stub 未实现导致 ccshareId 恒为 null,整个 ccshare 分支永不进入;保留的文件路径 resume 路径不变。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 environment-runner stub 及其 cli.tsx fast-path 与 self-hosted-runner 相同模式的 sibling(工作流 1 verifier 建议同步处理): - 删除 src/environment-runner/main.ts(自动生成的 Promise.resolve() stub) - 同步移除 src/entrypoints/cli.tsx 中 feature('BYOC_ENVIRONMENT_RUNNER') 守卫的 fast-path 分支 - 清理两个空目录(src/self-hosted-runner/、src/environment-runner/) BYOC_ENVIRONMENT_RUNNER flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除孤立诊断脚本 probe-local-wiring.ts #!/usr/bin/env bun shebang 的手动诊断脚本,全仓零引用,不在 package.json/build.ts/vite.config.ts/CI workflows 中。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 ultrareview preflight stub 及其测试 - 删除 src/services/api/ultrareviewPreflight.ts(自动生成的 stub) - 删除 src/commands/review/UltrareviewPreflightDialog.tsx(依赖前者的 UI stub) - 删除 src/services/api/__tests__/ultrareviewPreflight.test.ts(测试已删代码) - 同步移除 ultrareviewCommand.test.tsx 中对 UltrareviewPreflightDialog 的 mock Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 cachedMCConfig stub 及 prompts.ts 的 CACHED_MICROCOMPACT 死代码 - 删除 src/services/compact/cachedMCConfig.ts(自动生成的 stub) - 同步移除 src/constants/prompts.ts 中依赖该 stub 的代码: - getCachedMCConfigForFRC 变量(feature('CACHED_MICROCOMPACT') 守卫的 require) - getFunctionResultClearingSection 函数(约 18 行) - systemPrompt 数组中的 frc section 调用与注册 CACHED_MICROCOMPACT 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 goalAudit stub 及其测试引用 - 删除 src/services/goal/goalAudit.ts(导出 COMPLETION_AUDIT_RULES/BLOCKED_AUDIT_RULES/isGoalTerminal 等未引用的 stub) - 同步移除 tests/integration/goal-lifecycle.test.ts 中对 goalAudit 的 import 和一个测试用例(budget_limited is terminal) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 agentSdkTypes 第二批 not-implemented stub 移除运行时函数体仅为 throw new Error 或 placeholder 的 stub: - createSdkMcpToolDefinition、createSdkMcpServer - query 函数重载与实现 - unstable_v2_* 系列函数 - session 操作 stub(getSessionMessages/listSessions/getSessionInfo/renameSession/tagSession/forkSession) - AbortError 类 保留所有 export type 重导出和类型别名(仍是公共类型面)。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 Tool.ts 中 backwards-compat 重导出 shim 删除 "// Re-export progress types for backwards compatibility" 注释块及其重导出语句。所有消费方已直接从 src/types/tools.js 导入,无需重导出转发。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 bootstrap/state.ts 中 4 个未引用的 export - clearRegisteredHooks(STATE.registeredHooks 仍由其他函数管理) - getInvokedSkills(getInvokedSkillsForAgent 是活跃入口) - getSessionSource(setSessionSource 仍活跃,sessionSource state 字段保留) - markScrollActivity(scrollDraining/getIsScrollDraining/waitForScrollDrain 仍活跃) 仅删除孤儿访问器,不动模块级 state 副作用。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 src/ 下多处未引用的导出 涉及 18 个文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除: - bridge/bridgeStatusUtil.ts、components/TrustDialog/utils.ts、context/stats.tsx - keybindings/loadUserBindings.ts、memdir/paths.ts、remote/sdkMessageAdapter.ts - services/acp/utils.ts(删除 nodeToWebReadable,全仓零引用) - services/api/metricsOptOut.ts、services/lsp/LSPDiagnosticRegistry.ts、services/lsp/manager.ts - services/mcp/utils.ts、services/skillLearning/projectContext.ts - services/teamMemorySync/secretScanner.ts、services/teamMemorySync/watcher.ts - skills/loadSkillsDir.ts、utils/attachments.ts、utils/filePersistence/filePersistence.ts - utils/messageQueueManager.ts Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 packages/ 下多处未引用的导出 涉及 11 个 workspace 包文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除: - @ant/ink/core/termio/csi.ts(eraseLine) - acp-link/manager/types.ts、acp-link/ws-message.ts - builtin-tools/AgentTool/agentMemory.ts、BashTool/bashSecurity.ts、BashTool/sedEditParser.ts - builtin-tools/ConfigTool/supportedSettings.ts、FileEditTool/utils.ts - remote-control-server/store.ts、transport/event-bus.ts、types/messages.ts Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * Revert "fix: 终端内容溢出 viewport 时的重影 bug" This reverts commit3d18e1da58. * revert: 移除主屏幕周期性 self-healing 重绘 回退f69c7051中引入的 ink.tsx self-healing 机制(lastMainScreenHealTime 字段 + 每 5 秒触发全量重绘 + needsEraseBeforePaint 主屏幕分支)。该机制在 workflow 面板持续刷新场景下表现为可见的"重复刷新",且修复效果不稳定。 alt-screen 的 needsEraseBeforePaint 路径和 prevFrameContaminated 字段保留, 它们仍服务于 handleResize / layout shift / selection 高亮。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * fix: /workflows 面板默认只显示运行中 run,根治 tab 行乱码 之前几次渲染层修复都失败,因为没动 tab 列表的数据源:打开 /workflows 会 自动 hydrate 最多 20 个历史 done/killed run,全部塞进一行 TabsBar,超出 终端宽度后 Ink 把字符画到屏外造成重影乱码。 - selectors.ts 加 filterActiveRuns(只留 status === 'running')和 capTabsForDisplay(超额 fold 成 +N)两个 pure function - WorkflowsPanel 接线 activeRuns:focus clamp、focused、nextTab/prevTab、 TabsBar 全部基于过滤后的 activeRuns - TabsBar 复用 truncateLabel 限制每个 tab 名 18 字符 + 最多 6 个 tab, 多余显示 +N,从结构上钉死单行总宽度 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * fix: /workflows 面板 phase 状态在脚本省略 phase() 时显示错乱 ultracode canonical pipeline 脚本常在 agent() 直接传 opts.phase 而不调 phase() hook,导致 phase_started 从未发出;同时 phase_done 只在下次 phase() 触发,上一 个 phase 在 run.phases 里一直停在 running。mergePhases 之前把 actual 当权威, 于是出现 "Map 8/8 全 done 还显示 running、Find 1/4 running 反而显示 pending"。 改为派生层修复:mergePhases 新增 derivePhaseStatus——actual.status==='done' 权威;否则有 agents 就按 agents 状态推(全 done→done,否则 running);否则看 actual 是否 running。再补一层遍历,让只在 agents 上出现的 phase 也进 sidebar。 不改 store 状态语义,已有 state.json 无需迁移。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * docs: 更新 readme * fix: ACP 模式未读取 settings.local.json entry.ts 在 ACP 握手期调用的 applySafeConfigEnvironmentVariables 触发了 loadSettingsFromDisk,此时 getOriginalCwd() 还是进程启动 cwd(非项目目录), 导致 localSettings/projectSettings 按错误路径解析为空并被 session cache 锁住, 后续 createSession 里 setOriginalCwd 也无法纠正。在 setOriginalCwd 与 chdir 之后清缓存并重新应用,让 settings.local.json 和项目级 env 对 readSettingsPermissionMode 及下游可见。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> --------- Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
3114 lines
110 KiB
TypeScript
3114 lines
110 KiB
TypeScript
import { execFileSync } from 'child_process'
|
|
import { diffLines } from 'diff'
|
|
import { constants as fsConstants, type Dirent } from 'fs'
|
|
import {
|
|
copyFile,
|
|
mkdir,
|
|
mkdtemp,
|
|
readdir,
|
|
readFile,
|
|
rm,
|
|
unlink,
|
|
writeFile,
|
|
} from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { extname, join } from 'path'
|
|
import type { Command } from '../commands.js'
|
|
import { queryWithModel } from '../services/api/claude.js'
|
|
import {
|
|
AGENT_TOOL_NAME,
|
|
LEGACY_AGENT_TOOL_NAME,
|
|
} from '@claude-code-best/builtin-tools/tools/AgentTool/constants.js'
|
|
import type { LogOption } from '../types/logs.js'
|
|
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
|
import { toError } from '../utils/errors.js'
|
|
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
|
|
import { logError } from '../utils/log.js'
|
|
import { extractTextContent } from '../utils/messages.js'
|
|
import { getDefaultOpusModel } from '../utils/model/model.js'
|
|
import {
|
|
getProjectsDir,
|
|
getSessionFilesWithMtime,
|
|
getSessionIdFromLog,
|
|
loadAllLogsFromSessionFile,
|
|
} from '../utils/sessionStorage.js'
|
|
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
|
import { countCharInString } from '../utils/stringUtils.js'
|
|
import { asSystemPrompt } from '../utils/systemPromptType.js'
|
|
import { escapeXmlAttr as escapeHtml } from '../utils/xml.js'
|
|
|
|
// Model for facet extraction and summarization (Opus - best quality)
|
|
function getAnalysisModel(): string {
|
|
return getDefaultOpusModel()
|
|
}
|
|
|
|
// Model for narrative insights (Opus - best quality)
|
|
function getInsightsModel(): string {
|
|
return getDefaultOpusModel()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Homespace Data Collection
|
|
// ============================================================================
|
|
|
|
type RemoteHostInfo = {
|
|
name: string
|
|
sessionCount: number
|
|
}
|
|
|
|
/* eslint-disable custom-rules/no-process-env-top-level */
|
|
const getRunningRemoteHosts: () => Promise<string[]> =
|
|
process.env.USER_TYPE === 'ant'
|
|
? async () => {
|
|
const { stdout, code } = await execFileNoThrow(
|
|
'coder',
|
|
['list', '-o', 'json'],
|
|
{ timeout: 30000 },
|
|
)
|
|
if (code !== 0) return []
|
|
try {
|
|
const workspaces = jsonParse(stdout) as Array<{
|
|
name: string
|
|
latest_build?: { status?: string }
|
|
}>
|
|
return workspaces
|
|
.filter(w => w.latest_build?.status === 'running')
|
|
.map(w => w.name)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
: async () => []
|
|
|
|
const getRemoteHostSessionCount: (hs: string) => Promise<number> =
|
|
process.env.USER_TYPE === 'ant'
|
|
? async (homespace: string) => {
|
|
const { stdout, code } = await execFileNoThrow(
|
|
'ssh',
|
|
[
|
|
`${homespace}.coder`,
|
|
'find /root/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l',
|
|
],
|
|
{ timeout: 30000 },
|
|
)
|
|
if (code !== 0) return 0
|
|
return parseInt(stdout.trim(), 10) || 0
|
|
}
|
|
: async () => 0
|
|
|
|
const collectFromRemoteHost: (
|
|
hs: string,
|
|
destDir: string,
|
|
) => Promise<{ copied: number; skipped: number }> =
|
|
process.env.USER_TYPE === 'ant'
|
|
? async (homespace: string, destDir: string) => {
|
|
const result = { copied: 0, skipped: 0 }
|
|
|
|
// Create temp directory
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'claude-hs-'))
|
|
|
|
try {
|
|
// SCP the projects folder
|
|
const scpResult = await execFileNoThrow(
|
|
'scp',
|
|
['-rq', `${homespace}.coder:/root/.claude/projects/`, tempDir],
|
|
{ timeout: 300000 },
|
|
)
|
|
if (scpResult.code !== 0) {
|
|
// SCP failed
|
|
return result
|
|
}
|
|
|
|
const projectsDir = join(tempDir, 'projects')
|
|
let projectDirents: Dirent<string>[]
|
|
try {
|
|
projectDirents = await readdir(projectsDir, { withFileTypes: true })
|
|
} catch {
|
|
return result
|
|
}
|
|
|
|
// Merge into destination (parallel per project directory)
|
|
await Promise.all(
|
|
projectDirents.map(async dirent => {
|
|
const projectName = dirent.name
|
|
const projectPath = join(projectsDir, projectName)
|
|
|
|
// Skip if not a directory
|
|
if (!dirent.isDirectory()) return
|
|
|
|
const destProjectName = `${projectName}__${homespace}`
|
|
const destProjectPath = join(destDir, destProjectName)
|
|
|
|
try {
|
|
await mkdir(destProjectPath, { recursive: true })
|
|
} catch {
|
|
// Directory may already exist
|
|
}
|
|
|
|
// Copy session files (skip existing)
|
|
let files: Dirent<string>[]
|
|
try {
|
|
files = await readdir(projectPath, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
await Promise.all(
|
|
files.map(async fileDirent => {
|
|
const fileName = fileDirent.name
|
|
if (!fileName.endsWith('.jsonl')) return
|
|
|
|
const srcFile = join(projectPath, fileName)
|
|
const destFile = join(destProjectPath, fileName)
|
|
|
|
try {
|
|
await copyFile(srcFile, destFile, fsConstants.COPYFILE_EXCL)
|
|
result.copied++
|
|
} catch {
|
|
// EEXIST from COPYFILE_EXCL means dest already exists
|
|
result.skipped++
|
|
}
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
} finally {
|
|
try {
|
|
await rm(tempDir, { recursive: true, force: true })
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
: async () => ({ copied: 0, skipped: 0 })
|
|
|
|
const collectAllRemoteHostData: (destDir: string) => Promise<{
|
|
hosts: RemoteHostInfo[]
|
|
totalCopied: number
|
|
totalSkipped: number
|
|
}> =
|
|
process.env.USER_TYPE === 'ant'
|
|
? async (destDir: string) => {
|
|
const rHosts = await getRunningRemoteHosts()
|
|
const result: RemoteHostInfo[] = []
|
|
let totalCopied = 0
|
|
let totalSkipped = 0
|
|
|
|
// Collect from all hosts in parallel (SCP per host can take seconds)
|
|
const hostResults = await Promise.all(
|
|
rHosts.map(async hs => {
|
|
const sessionCount = await getRemoteHostSessionCount(hs)
|
|
if (sessionCount > 0) {
|
|
const { copied, skipped } = await collectFromRemoteHost(
|
|
hs,
|
|
destDir,
|
|
)
|
|
return { name: hs, sessionCount, copied, skipped }
|
|
}
|
|
return { name: hs, sessionCount, copied: 0, skipped: 0 }
|
|
}),
|
|
)
|
|
|
|
for (const hr of hostResults) {
|
|
result.push({ name: hr.name, sessionCount: hr.sessionCount })
|
|
totalCopied += hr.copied
|
|
totalSkipped += hr.skipped
|
|
}
|
|
|
|
return { hosts: result, totalCopied, totalSkipped }
|
|
}
|
|
: async () => ({ hosts: [], totalCopied: 0, totalSkipped: 0 })
|
|
/* eslint-enable custom-rules/no-process-env-top-level */
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
type SessionMeta = {
|
|
session_id: string
|
|
project_path: string
|
|
start_time: string
|
|
duration_minutes: number
|
|
user_message_count: number
|
|
assistant_message_count: number
|
|
tool_counts: Record<string, number>
|
|
languages: Record<string, number>
|
|
git_commits: number
|
|
git_pushes: number
|
|
input_tokens: number
|
|
output_tokens: number
|
|
first_prompt: string
|
|
summary?: string
|
|
// New stats
|
|
user_interruptions: number
|
|
user_response_times: number[]
|
|
tool_errors: number
|
|
tool_error_categories: Record<string, number>
|
|
uses_task_agent: boolean
|
|
uses_mcp: boolean
|
|
uses_web_search: boolean
|
|
uses_web_fetch: boolean
|
|
// Additional stats
|
|
lines_added: number
|
|
lines_removed: number
|
|
files_modified: number
|
|
message_hours: number[]
|
|
user_message_timestamps: string[] // ISO timestamps for multi-clauding detection
|
|
}
|
|
|
|
type SessionFacets = {
|
|
session_id: string
|
|
underlying_goal: string
|
|
goal_categories: Record<string, number>
|
|
outcome: string
|
|
user_satisfaction_counts: Record<string, number>
|
|
claude_helpfulness: string
|
|
session_type: string
|
|
friction_counts: Record<string, number>
|
|
friction_detail: string
|
|
primary_success: string
|
|
brief_summary: string
|
|
user_instructions_to_claude?: string[]
|
|
}
|
|
|
|
type AggregatedData = {
|
|
total_sessions: number
|
|
total_sessions_scanned?: number
|
|
sessions_with_facets: number
|
|
date_range: { start: string; end: string }
|
|
total_messages: number
|
|
total_duration_hours: number
|
|
total_input_tokens: number
|
|
total_output_tokens: number
|
|
tool_counts: Record<string, number>
|
|
languages: Record<string, number>
|
|
git_commits: number
|
|
git_pushes: number
|
|
projects: Record<string, number>
|
|
goal_categories: Record<string, number>
|
|
outcomes: Record<string, number>
|
|
satisfaction: Record<string, number>
|
|
helpfulness: Record<string, number>
|
|
session_types: Record<string, number>
|
|
friction: Record<string, number>
|
|
success: Record<string, number>
|
|
session_summaries: Array<{
|
|
id: string
|
|
date: string
|
|
summary: string
|
|
goal?: string
|
|
}>
|
|
// New aggregated stats
|
|
total_interruptions: number
|
|
total_tool_errors: number
|
|
tool_error_categories: Record<string, number>
|
|
user_response_times: number[]
|
|
median_response_time: number
|
|
avg_response_time: number
|
|
sessions_using_task_agent: number
|
|
sessions_using_mcp: number
|
|
sessions_using_web_search: number
|
|
sessions_using_web_fetch: number
|
|
// Additional stats from Python reference
|
|
total_lines_added: number
|
|
total_lines_removed: number
|
|
total_files_modified: number
|
|
days_active: number
|
|
messages_per_day: number
|
|
message_hours: number[] // Hour of day for each user message (for time of day chart)
|
|
// Multi-clauding stats (matching Python reference)
|
|
multi_clauding: {
|
|
overlap_events: number
|
|
sessions_involved: number
|
|
user_messages_during: number
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
|
'.ts': 'TypeScript',
|
|
'.tsx': 'TypeScript',
|
|
'.js': 'JavaScript',
|
|
'.jsx': 'JavaScript',
|
|
'.py': 'Python',
|
|
'.rb': 'Ruby',
|
|
'.go': 'Go',
|
|
'.rs': 'Rust',
|
|
'.java': 'Java',
|
|
'.md': 'Markdown',
|
|
'.json': 'JSON',
|
|
'.yaml': 'YAML',
|
|
'.yml': 'YAML',
|
|
'.sh': 'Shell',
|
|
'.css': 'CSS',
|
|
'.html': 'HTML',
|
|
}
|
|
|
|
// Label map for cleaning up category names (matching Python reference)
|
|
const LABEL_MAP: Record<string, string> = {
|
|
// Goal categories
|
|
debug_investigate: 'Debug/Investigate',
|
|
implement_feature: 'Implement Feature',
|
|
fix_bug: 'Fix Bug',
|
|
write_script_tool: 'Write Script/Tool',
|
|
refactor_code: 'Refactor Code',
|
|
configure_system: 'Configure System',
|
|
create_pr_commit: 'Create PR/Commit',
|
|
analyze_data: 'Analyze Data',
|
|
understand_codebase: 'Understand Codebase',
|
|
write_tests: 'Write Tests',
|
|
write_docs: 'Write Docs',
|
|
deploy_infra: 'Deploy/Infra',
|
|
warmup_minimal: 'Cache Warmup',
|
|
// Success factors
|
|
fast_accurate_search: 'Fast/Accurate Search',
|
|
correct_code_edits: 'Correct Code Edits',
|
|
good_explanations: 'Good Explanations',
|
|
proactive_help: 'Proactive Help',
|
|
multi_file_changes: 'Multi-file Changes',
|
|
handled_complexity: 'Multi-file Changes',
|
|
good_debugging: 'Good Debugging',
|
|
// Friction types
|
|
misunderstood_request: 'Misunderstood Request',
|
|
wrong_approach: 'Wrong Approach',
|
|
buggy_code: 'Buggy Code',
|
|
user_rejected_action: 'User Rejected Action',
|
|
claude_got_blocked: 'Claude Got Blocked',
|
|
user_stopped_early: 'User Stopped Early',
|
|
wrong_file_or_location: 'Wrong File/Location',
|
|
excessive_changes: 'Excessive Changes',
|
|
slow_or_verbose: 'Slow/Verbose',
|
|
tool_failed: 'Tool Failed',
|
|
user_unclear: 'User Unclear',
|
|
external_issue: 'External Issue',
|
|
// Satisfaction labels
|
|
frustrated: 'Frustrated',
|
|
dissatisfied: 'Dissatisfied',
|
|
likely_satisfied: 'Likely Satisfied',
|
|
satisfied: 'Satisfied',
|
|
happy: 'Happy',
|
|
unsure: 'Unsure',
|
|
neutral: 'Neutral',
|
|
delighted: 'Delighted',
|
|
// Session types
|
|
single_task: 'Single Task',
|
|
multi_task: 'Multi Task',
|
|
iterative_refinement: 'Iterative Refinement',
|
|
exploration: 'Exploration',
|
|
quick_question: 'Quick Question',
|
|
// Outcomes
|
|
fully_achieved: 'Fully Achieved',
|
|
mostly_achieved: 'Mostly Achieved',
|
|
partially_achieved: 'Partially Achieved',
|
|
not_achieved: 'Not Achieved',
|
|
unclear_from_transcript: 'Unclear',
|
|
// Helpfulness
|
|
unhelpful: 'Unhelpful',
|
|
slightly_helpful: 'Slightly Helpful',
|
|
moderately_helpful: 'Moderately Helpful',
|
|
very_helpful: 'Very Helpful',
|
|
essential: 'Essential',
|
|
}
|
|
|
|
// Lazy getters: getClaudeConfigHomeDir() is memoized and reads process.env.
|
|
// Calling it at module scope would populate the memoize cache before
|
|
// entrypoints can set CLAUDE_CONFIG_DIR, breaking all 150+ other callers.
|
|
function getDataDir(): string {
|
|
return join(getClaudeConfigHomeDir(), 'usage-data')
|
|
}
|
|
function getFacetsDir(): string {
|
|
return join(getDataDir(), 'facets')
|
|
}
|
|
function getSessionMetaDir(): string {
|
|
return join(getDataDir(), 'session-meta')
|
|
}
|
|
|
|
const FACET_EXTRACTION_PROMPT = `Analyze this Claude Code session and extract structured facets.
|
|
|
|
CRITICAL GUIDELINES:
|
|
|
|
1. **goal_categories**: Count ONLY what the USER explicitly asked for.
|
|
- DO NOT count Claude's autonomous codebase exploration
|
|
- DO NOT count work Claude decided to do on its own
|
|
- ONLY count when user says "can you...", "please...", "I need...", "let's..."
|
|
|
|
2. **user_satisfaction_counts**: Base ONLY on explicit user signals.
|
|
- "Yay!", "great!", "perfect!" → happy
|
|
- "thanks", "looks good", "that works" → satisfied
|
|
- "ok, now let's..." (continuing without complaint) → likely_satisfied
|
|
- "that's not right", "try again" → dissatisfied
|
|
- "this is broken", "I give up" → frustrated
|
|
|
|
3. **friction_counts**: Be specific about what went wrong.
|
|
- misunderstood_request: Claude interpreted incorrectly
|
|
- wrong_approach: Right goal, wrong solution method
|
|
- buggy_code: Code didn't work correctly
|
|
- user_rejected_action: User said no/stop to a tool call
|
|
- excessive_changes: Over-engineered or changed too much
|
|
|
|
4. If very short or just warmup, use warmup_minimal for goal_category
|
|
|
|
SESSION:
|
|
`
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
function getLanguageFromPath(filePath: string): string | null {
|
|
const ext = extname(filePath).toLowerCase()
|
|
return EXTENSION_TO_LANGUAGE[ext] || null
|
|
}
|
|
|
|
function extractToolStats(log: LogOption): {
|
|
toolCounts: Record<string, number>
|
|
languages: Record<string, number>
|
|
gitCommits: number
|
|
gitPushes: number
|
|
inputTokens: number
|
|
outputTokens: number
|
|
// New stats
|
|
userInterruptions: number
|
|
userResponseTimes: number[]
|
|
toolErrors: number
|
|
toolErrorCategories: Record<string, number>
|
|
usesTaskAgent: boolean
|
|
usesMcp: boolean
|
|
usesWebSearch: boolean
|
|
usesWebFetch: boolean
|
|
// Additional stats
|
|
linesAdded: number
|
|
linesRemoved: number
|
|
filesModified: Set<string>
|
|
messageHours: number[]
|
|
userMessageTimestamps: string[] // ISO timestamps for multi-clauding detection
|
|
} {
|
|
const toolCounts: Record<string, number> = {}
|
|
const languages: Record<string, number> = {}
|
|
let gitCommits = 0
|
|
let gitPushes = 0
|
|
let inputTokens = 0
|
|
let outputTokens = 0
|
|
|
|
// New stats
|
|
let userInterruptions = 0
|
|
const userResponseTimes: number[] = []
|
|
let toolErrors = 0
|
|
const toolErrorCategories: Record<string, number> = {}
|
|
let usesTaskAgent = false
|
|
|
|
// Additional stats
|
|
let linesAdded = 0
|
|
let linesRemoved = 0
|
|
const filesModified = new Set<string>()
|
|
const messageHours: number[] = []
|
|
const userMessageTimestamps: string[] = [] // For multi-clauding detection
|
|
let usesMcp = false
|
|
let usesWebSearch = false
|
|
let usesWebFetch = false
|
|
let lastAssistantTimestamp: string | null = null
|
|
|
|
for (const msg of log.messages) {
|
|
// Get message timestamp for response time calculation
|
|
const msgTimestamp = (msg as { timestamp?: string }).timestamp
|
|
|
|
if (msg.type === 'assistant' && msg.message) {
|
|
// Track timestamp for response time calculation
|
|
if (msgTimestamp) {
|
|
lastAssistantTimestamp = msgTimestamp
|
|
}
|
|
|
|
const usage = (
|
|
msg.message as {
|
|
usage?: { input_tokens?: number; output_tokens?: number }
|
|
}
|
|
).usage
|
|
if (usage) {
|
|
inputTokens += usage.input_tokens || 0
|
|
outputTokens += usage.output_tokens || 0
|
|
}
|
|
|
|
const content = msg.message.content
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'tool_use' && 'name' in block) {
|
|
const toolName = block.name as string
|
|
toolCounts[toolName] = (toolCounts[toolName] || 0) + 1
|
|
|
|
// Check for special tool usage
|
|
if (
|
|
toolName === AGENT_TOOL_NAME ||
|
|
toolName === LEGACY_AGENT_TOOL_NAME
|
|
)
|
|
usesTaskAgent = true
|
|
if (toolName.startsWith('mcp__')) usesMcp = true
|
|
if (toolName === 'WebSearch') usesWebSearch = true
|
|
if (toolName === 'WebFetch') usesWebFetch = true
|
|
|
|
const input = (block as { input?: Record<string, unknown> }).input
|
|
|
|
if (input) {
|
|
const filePath = (input.file_path as string) || ''
|
|
if (filePath) {
|
|
const lang = getLanguageFromPath(filePath)
|
|
if (lang) {
|
|
languages[lang] = (languages[lang] || 0) + 1
|
|
}
|
|
// Track files modified by Edit/Write tools
|
|
if (toolName === 'Edit' || toolName === 'Write') {
|
|
filesModified.add(filePath)
|
|
}
|
|
}
|
|
|
|
if (toolName === 'Edit') {
|
|
const oldString = (input.old_string as string) || ''
|
|
const newString = (input.new_string as string) || ''
|
|
for (const change of diffLines(oldString, newString)) {
|
|
if (change.added) linesAdded += change.count || 0
|
|
if (change.removed) linesRemoved += change.count || 0
|
|
}
|
|
}
|
|
|
|
// Track lines from Write tool (all added)
|
|
if (toolName === 'Write') {
|
|
const writeContent = (input.content as string) || ''
|
|
if (writeContent) {
|
|
linesAdded += countCharInString(writeContent, '\n') + 1
|
|
}
|
|
}
|
|
|
|
const command = (input.command as string) || ''
|
|
if (command.includes('git commit')) gitCommits++
|
|
if (command.includes('git push')) gitPushes++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check user messages
|
|
if (msg.type === 'user' && msg.message) {
|
|
const content = msg.message.content
|
|
|
|
// Check if this is an actual human message (has text) vs just tool_result
|
|
// matching Python reference logic
|
|
let isHumanMessage = false
|
|
if (typeof content === 'string' && content.trim()) {
|
|
isHumanMessage = true
|
|
} else if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'text' && 'text' in block) {
|
|
isHumanMessage = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only track message hours and response times for actual human messages
|
|
if (isHumanMessage) {
|
|
// Track message hour for time-of-day analysis and timestamp for multi-clauding
|
|
if (msgTimestamp) {
|
|
try {
|
|
const msgDate = new Date(msgTimestamp)
|
|
const hour = msgDate.getHours() // Local hour 0-23
|
|
messageHours.push(hour)
|
|
// Collect timestamp for multi-clauding detection (matching Python)
|
|
userMessageTimestamps.push(msgTimestamp)
|
|
} catch {
|
|
// Skip invalid timestamps
|
|
}
|
|
}
|
|
|
|
// Calculate response time (time from last assistant message to this user message)
|
|
// Only count gaps > 2 seconds (real user think time, not tool results)
|
|
if (lastAssistantTimestamp && msgTimestamp) {
|
|
const assistantTime = new Date(lastAssistantTimestamp).getTime()
|
|
const userTime = new Date(msgTimestamp).getTime()
|
|
const responseTimeSec = (userTime - assistantTime) / 1000
|
|
// Only count reasonable response times (2s-1 hour) matching Python
|
|
if (responseTimeSec > 2 && responseTimeSec < 3600) {
|
|
userResponseTimes.push(responseTimeSec)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process tool results (for error tracking)
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'tool_result' && 'content' in block) {
|
|
const isError = (block as { is_error?: boolean }).is_error
|
|
|
|
// Count and categorize tool errors (matching Python reference logic)
|
|
if (isError) {
|
|
toolErrors++
|
|
const resultContent = (block as { content?: string }).content
|
|
let category = 'Other'
|
|
if (typeof resultContent === 'string') {
|
|
const lowerContent = resultContent.toLowerCase()
|
|
if (lowerContent.includes('exit code')) {
|
|
category = 'Command Failed'
|
|
} else if (
|
|
lowerContent.includes('rejected') ||
|
|
lowerContent.includes("doesn't want")
|
|
) {
|
|
category = 'User Rejected'
|
|
} else if (
|
|
lowerContent.includes('string to replace not found') ||
|
|
lowerContent.includes('no changes')
|
|
) {
|
|
category = 'Edit Failed'
|
|
} else if (lowerContent.includes('modified since read')) {
|
|
category = 'File Changed'
|
|
} else if (
|
|
lowerContent.includes('exceeds maximum') ||
|
|
lowerContent.includes('too large')
|
|
) {
|
|
category = 'File Too Large'
|
|
} else if (
|
|
lowerContent.includes('file not found') ||
|
|
lowerContent.includes('does not exist')
|
|
) {
|
|
category = 'File Not Found'
|
|
}
|
|
}
|
|
toolErrorCategories[category] =
|
|
(toolErrorCategories[category] || 0) + 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for interruptions (matching Python reference)
|
|
if (typeof content === 'string') {
|
|
if (content.includes('[Request interrupted by user')) {
|
|
userInterruptions++
|
|
}
|
|
} else if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (
|
|
block.type === 'text' &&
|
|
'text' in block &&
|
|
(block.text as string).includes('[Request interrupted by user')
|
|
) {
|
|
userInterruptions++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
toolCounts,
|
|
languages,
|
|
gitCommits,
|
|
gitPushes,
|
|
inputTokens,
|
|
outputTokens,
|
|
// New stats
|
|
userInterruptions,
|
|
userResponseTimes,
|
|
toolErrors,
|
|
toolErrorCategories,
|
|
usesTaskAgent,
|
|
usesMcp,
|
|
usesWebSearch,
|
|
usesWebFetch,
|
|
// Additional stats
|
|
linesAdded,
|
|
linesRemoved,
|
|
filesModified,
|
|
messageHours,
|
|
userMessageTimestamps,
|
|
}
|
|
}
|
|
|
|
function hasValidDates(log: LogOption): boolean {
|
|
return (
|
|
!Number.isNaN(log.created.getTime()) &&
|
|
!Number.isNaN(log.modified.getTime())
|
|
)
|
|
}
|
|
|
|
function logToSessionMeta(log: LogOption): SessionMeta {
|
|
const stats = extractToolStats(log)
|
|
const sessionId = getSessionIdFromLog(log) || 'unknown'
|
|
const startTime = log.created.toISOString()
|
|
const durationMinutes = Math.round(
|
|
(log.modified.getTime() - log.created.getTime()) / 1000 / 60,
|
|
)
|
|
|
|
let userMessageCount = 0
|
|
let assistantMessageCount = 0
|
|
for (const msg of log.messages) {
|
|
if (msg.type === 'assistant') assistantMessageCount++
|
|
// Only count user messages that have actual text content (human messages)
|
|
// not just tool_result messages (matching Python reference)
|
|
if (msg.type === 'user' && msg.message) {
|
|
const content = msg.message.content
|
|
let isHumanMessage = false
|
|
if (typeof content === 'string' && content.trim()) {
|
|
isHumanMessage = true
|
|
} else if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'text' && 'text' in block) {
|
|
isHumanMessage = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (isHumanMessage) {
|
|
userMessageCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
session_id: sessionId,
|
|
project_path: log.projectPath || '',
|
|
start_time: startTime,
|
|
duration_minutes: durationMinutes,
|
|
user_message_count: userMessageCount,
|
|
assistant_message_count: assistantMessageCount,
|
|
tool_counts: stats.toolCounts,
|
|
languages: stats.languages,
|
|
git_commits: stats.gitCommits,
|
|
git_pushes: stats.gitPushes,
|
|
input_tokens: stats.inputTokens,
|
|
output_tokens: stats.outputTokens,
|
|
first_prompt: log.firstPrompt || '',
|
|
summary: log.summary,
|
|
// New stats
|
|
user_interruptions: stats.userInterruptions,
|
|
user_response_times: stats.userResponseTimes,
|
|
tool_errors: stats.toolErrors,
|
|
tool_error_categories: stats.toolErrorCategories,
|
|
uses_task_agent: stats.usesTaskAgent,
|
|
uses_mcp: stats.usesMcp,
|
|
uses_web_search: stats.usesWebSearch,
|
|
uses_web_fetch: stats.usesWebFetch,
|
|
// Additional stats
|
|
lines_added: stats.linesAdded,
|
|
lines_removed: stats.linesRemoved,
|
|
files_modified: stats.filesModified.size,
|
|
message_hours: stats.messageHours,
|
|
user_message_timestamps: stats.userMessageTimestamps,
|
|
}
|
|
}
|
|
|
|
function formatTranscriptForFacets(log: LogOption): string {
|
|
const lines: string[] = []
|
|
const meta = logToSessionMeta(log)
|
|
|
|
lines.push(`Session: ${meta.session_id.slice(0, 8)}`)
|
|
lines.push(`Date: ${meta.start_time}`)
|
|
lines.push(`Project: ${meta.project_path}`)
|
|
lines.push(`Duration: ${meta.duration_minutes} min`)
|
|
lines.push('')
|
|
|
|
for (const msg of log.messages) {
|
|
if (msg.type === 'user' && msg.message) {
|
|
const content = msg.message.content
|
|
if (typeof content === 'string') {
|
|
lines.push(`[User]: ${content.slice(0, 500)}`)
|
|
} else if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'text' && 'text' in block) {
|
|
lines.push(`[User]: ${(block.text as string).slice(0, 500)}`)
|
|
}
|
|
}
|
|
}
|
|
} else if (msg.type === 'assistant' && msg.message) {
|
|
const content = msg.message.content
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'text' && 'text' in block) {
|
|
lines.push(`[Assistant]: ${(block.text as string).slice(0, 300)}`)
|
|
} else if (block.type === 'tool_use' && 'name' in block) {
|
|
lines.push(`[Tool: ${block.name}]`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a Claude Code session transcript. Focus on:
|
|
1. What the user asked for
|
|
2. What Claude did (tools used, files modified)
|
|
3. Any friction or issues
|
|
4. The outcome
|
|
|
|
Keep it concise - 3-5 sentences. Preserve specific details like file names, error messages, and user feedback.
|
|
|
|
TRANSCRIPT CHUNK:
|
|
`
|
|
|
|
async function summarizeTranscriptChunk(chunk: string): Promise<string> {
|
|
try {
|
|
const result = await queryWithModel({
|
|
systemPrompt: asSystemPrompt([]),
|
|
userPrompt: SUMMARIZE_CHUNK_PROMPT + chunk,
|
|
signal: new AbortController().signal,
|
|
options: {
|
|
model: getAnalysisModel(),
|
|
querySource: 'insights',
|
|
agents: [],
|
|
isNonInteractiveSession: true,
|
|
hasAppendSystemPrompt: false,
|
|
mcpTools: [],
|
|
maxOutputTokensOverride: 500,
|
|
},
|
|
})
|
|
|
|
const text = extractTextContent(
|
|
result.message.content as readonly { readonly type: string }[],
|
|
)
|
|
return text || chunk.slice(0, 2000)
|
|
} catch {
|
|
// On error, just return truncated chunk
|
|
return chunk.slice(0, 2000)
|
|
}
|
|
}
|
|
|
|
async function formatTranscriptWithSummarization(
|
|
log: LogOption,
|
|
): Promise<string> {
|
|
const fullTranscript = formatTranscriptForFacets(log)
|
|
|
|
// If under 30k chars, use as-is
|
|
if (fullTranscript.length <= 30000) {
|
|
return fullTranscript
|
|
}
|
|
|
|
// For long transcripts, split into chunks and summarize in parallel
|
|
const CHUNK_SIZE = 25000
|
|
const chunks: string[] = []
|
|
|
|
for (let i = 0; i < fullTranscript.length; i += CHUNK_SIZE) {
|
|
chunks.push(fullTranscript.slice(i, i + CHUNK_SIZE))
|
|
}
|
|
|
|
// Summarize all chunks in parallel
|
|
const summaries = await Promise.all(chunks.map(summarizeTranscriptChunk))
|
|
|
|
// Combine summaries with session header
|
|
const meta = logToSessionMeta(log)
|
|
const header = [
|
|
`Session: ${meta.session_id.slice(0, 8)}`,
|
|
`Date: ${meta.start_time}`,
|
|
`Project: ${meta.project_path}`,
|
|
`Duration: ${meta.duration_minutes} min`,
|
|
`[Long session - ${chunks.length} parts summarized]`,
|
|
'',
|
|
].join('\n')
|
|
|
|
return header + summaries.join('\n\n---\n\n')
|
|
}
|
|
|
|
async function loadCachedFacets(
|
|
sessionId: string,
|
|
): Promise<SessionFacets | null> {
|
|
const facetPath = join(getFacetsDir(), `${sessionId}.json`)
|
|
try {
|
|
const content = await readFile(facetPath, { encoding: 'utf-8' })
|
|
const parsed: unknown = jsonParse(content)
|
|
if (!isValidSessionFacets(parsed)) {
|
|
// Delete corrupted cache file so it gets re-extracted next run
|
|
try {
|
|
await unlink(facetPath)
|
|
} catch {
|
|
// Ignore deletion errors
|
|
}
|
|
return null
|
|
}
|
|
return parsed
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function saveFacets(facets: SessionFacets): Promise<void> {
|
|
try {
|
|
await mkdir(getFacetsDir(), { recursive: true })
|
|
} catch {
|
|
// Directory may already exist
|
|
}
|
|
const facetPath = join(getFacetsDir(), `${facets.session_id}.json`)
|
|
await writeFile(facetPath, jsonStringify(facets, null, 2), {
|
|
encoding: 'utf-8',
|
|
mode: 0o600,
|
|
})
|
|
}
|
|
|
|
async function loadCachedSessionMeta(
|
|
sessionId: string,
|
|
): Promise<SessionMeta | null> {
|
|
const metaPath = join(getSessionMetaDir(), `${sessionId}.json`)
|
|
try {
|
|
const content = await readFile(metaPath, { encoding: 'utf-8' })
|
|
return jsonParse(content)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function saveSessionMeta(meta: SessionMeta): Promise<void> {
|
|
try {
|
|
await mkdir(getSessionMetaDir(), { recursive: true })
|
|
} catch {
|
|
// Directory may already exist
|
|
}
|
|
const metaPath = join(getSessionMetaDir(), `${meta.session_id}.json`)
|
|
await writeFile(metaPath, jsonStringify(meta, null, 2), {
|
|
encoding: 'utf-8',
|
|
mode: 0o600,
|
|
})
|
|
}
|
|
|
|
async function extractFacetsFromAPI(
|
|
log: LogOption,
|
|
sessionId: string,
|
|
): Promise<SessionFacets | null> {
|
|
try {
|
|
// Use summarization for long transcripts
|
|
const transcript = await formatTranscriptWithSummarization(log)
|
|
|
|
// Build prompt asking for JSON directly (no tool use)
|
|
const jsonPrompt = `${FACET_EXTRACTION_PROMPT}${transcript}
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
|
|
{
|
|
"underlying_goal": "What the user fundamentally wanted to achieve",
|
|
"goal_categories": {"category_name": count, ...},
|
|
"outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript",
|
|
"user_satisfaction_counts": {"level": count, ...},
|
|
"claude_helpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential",
|
|
"session_type": "single_task|multi_task|iterative_refinement|exploration|quick_question",
|
|
"friction_counts": {"friction_type": count, ...},
|
|
"friction_detail": "One sentence describing friction or empty",
|
|
"primary_success": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging",
|
|
"brief_summary": "One sentence: what user wanted and whether they got it"
|
|
}`
|
|
|
|
const result = await queryWithModel({
|
|
systemPrompt: asSystemPrompt([]),
|
|
userPrompt: jsonPrompt,
|
|
signal: new AbortController().signal,
|
|
options: {
|
|
model: getAnalysisModel(),
|
|
querySource: 'insights',
|
|
agents: [],
|
|
isNonInteractiveSession: true,
|
|
hasAppendSystemPrompt: false,
|
|
mcpTools: [],
|
|
maxOutputTokensOverride: 4096,
|
|
},
|
|
})
|
|
|
|
const text = extractTextContent(
|
|
result.message.content as readonly { readonly type: string }[],
|
|
)
|
|
|
|
// Parse JSON from response
|
|
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
|
if (!jsonMatch) return null
|
|
|
|
const parsed: unknown = jsonParse(jsonMatch[0])
|
|
if (!isValidSessionFacets(parsed)) return null
|
|
const facets: SessionFacets = { ...parsed, session_id: sessionId }
|
|
return facets
|
|
} catch (err) {
|
|
logError(new Error(`Facet extraction failed: ${toError(err).message}`))
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects multi-clauding (using multiple Claude sessions concurrently).
|
|
* Uses a sliding window to find the pattern: session1 -> session2 -> session1
|
|
* within a 30-minute window.
|
|
*/
|
|
export function detectMultiClauding(
|
|
sessions: Array<{
|
|
session_id: string
|
|
user_message_timestamps: string[]
|
|
}>,
|
|
): {
|
|
overlap_events: number
|
|
sessions_involved: number
|
|
user_messages_during: number
|
|
} {
|
|
const OVERLAP_WINDOW_MS = 30 * 60000
|
|
const allSessionMessages: Array<{ ts: number; sessionId: string }> = []
|
|
|
|
for (const session of sessions) {
|
|
for (const timestamp of session.user_message_timestamps) {
|
|
try {
|
|
const ts = new Date(timestamp).getTime()
|
|
allSessionMessages.push({ ts, sessionId: session.session_id })
|
|
} catch {
|
|
// Skip invalid timestamps
|
|
}
|
|
}
|
|
}
|
|
|
|
allSessionMessages.sort((a, b) => a.ts - b.ts)
|
|
|
|
const multiClaudeSessionPairs = new Set<string>()
|
|
const messagesDuringMulticlaude = new Set<string>()
|
|
|
|
// Sliding window: sessionLastIndex tracks the most recent index for each session
|
|
let windowStart = 0
|
|
const sessionLastIndex = new Map<string, number>()
|
|
|
|
for (let i = 0; i < allSessionMessages.length; i++) {
|
|
const msg = allSessionMessages[i]!
|
|
|
|
// Shrink window from the left
|
|
while (
|
|
windowStart < i &&
|
|
msg.ts - allSessionMessages[windowStart]!.ts > OVERLAP_WINDOW_MS
|
|
) {
|
|
const expiring = allSessionMessages[windowStart]!
|
|
if (sessionLastIndex.get(expiring.sessionId) === windowStart) {
|
|
sessionLastIndex.delete(expiring.sessionId)
|
|
}
|
|
windowStart++
|
|
}
|
|
|
|
// Check if this session appeared earlier in the window (pattern: s1 -> s2 -> s1)
|
|
const prevIndex = sessionLastIndex.get(msg.sessionId)
|
|
if (prevIndex !== undefined) {
|
|
for (let j = prevIndex + 1; j < i; j++) {
|
|
const between = allSessionMessages[j]!
|
|
if (between.sessionId !== msg.sessionId) {
|
|
const pair = [msg.sessionId, between.sessionId].sort().join(':')
|
|
multiClaudeSessionPairs.add(pair)
|
|
messagesDuringMulticlaude.add(
|
|
`${allSessionMessages[prevIndex]!.ts}:${msg.sessionId}`,
|
|
)
|
|
messagesDuringMulticlaude.add(`${between.ts}:${between.sessionId}`)
|
|
messagesDuringMulticlaude.add(`${msg.ts}:${msg.sessionId}`)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
sessionLastIndex.set(msg.sessionId, i)
|
|
}
|
|
|
|
const sessionsWithOverlaps = new Set<string>()
|
|
for (const pair of multiClaudeSessionPairs) {
|
|
const [s1, s2] = pair.split(':')
|
|
if (s1) sessionsWithOverlaps.add(s1)
|
|
if (s2) sessionsWithOverlaps.add(s2)
|
|
}
|
|
|
|
return {
|
|
overlap_events: multiClaudeSessionPairs.size,
|
|
sessions_involved: sessionsWithOverlaps.size,
|
|
user_messages_during: messagesDuringMulticlaude.size,
|
|
}
|
|
}
|
|
|
|
function aggregateData(
|
|
sessions: SessionMeta[],
|
|
facets: Map<string, SessionFacets>,
|
|
): AggregatedData {
|
|
const result: AggregatedData = {
|
|
total_sessions: sessions.length,
|
|
sessions_with_facets: facets.size,
|
|
date_range: { start: '', end: '' },
|
|
total_messages: 0,
|
|
total_duration_hours: 0,
|
|
total_input_tokens: 0,
|
|
total_output_tokens: 0,
|
|
tool_counts: {},
|
|
languages: {},
|
|
git_commits: 0,
|
|
git_pushes: 0,
|
|
projects: {},
|
|
goal_categories: {},
|
|
outcomes: {},
|
|
satisfaction: {},
|
|
helpfulness: {},
|
|
session_types: {},
|
|
friction: {},
|
|
success: {},
|
|
session_summaries: [],
|
|
// New stats
|
|
total_interruptions: 0,
|
|
total_tool_errors: 0,
|
|
tool_error_categories: {},
|
|
user_response_times: [],
|
|
median_response_time: 0,
|
|
avg_response_time: 0,
|
|
sessions_using_task_agent: 0,
|
|
sessions_using_mcp: 0,
|
|
sessions_using_web_search: 0,
|
|
sessions_using_web_fetch: 0,
|
|
// Additional stats
|
|
total_lines_added: 0,
|
|
total_lines_removed: 0,
|
|
total_files_modified: 0,
|
|
days_active: 0,
|
|
messages_per_day: 0,
|
|
message_hours: [],
|
|
// Multi-clauding stats (matching Python reference)
|
|
multi_clauding: {
|
|
overlap_events: 0,
|
|
sessions_involved: 0,
|
|
user_messages_during: 0,
|
|
},
|
|
}
|
|
|
|
const dates: string[] = []
|
|
const allResponseTimes: number[] = []
|
|
const allMessageHours: number[] = []
|
|
|
|
for (const session of sessions) {
|
|
dates.push(session.start_time)
|
|
result.total_messages += session.user_message_count
|
|
result.total_duration_hours += session.duration_minutes / 60
|
|
result.total_input_tokens += session.input_tokens
|
|
result.total_output_tokens += session.output_tokens
|
|
result.git_commits += session.git_commits
|
|
result.git_pushes += session.git_pushes
|
|
|
|
// New stats aggregation
|
|
result.total_interruptions += session.user_interruptions
|
|
result.total_tool_errors += session.tool_errors
|
|
for (const [cat, count] of Object.entries(session.tool_error_categories)) {
|
|
result.tool_error_categories[cat] =
|
|
(result.tool_error_categories[cat] || 0) + count
|
|
}
|
|
allResponseTimes.push(...session.user_response_times)
|
|
if (session.uses_task_agent) result.sessions_using_task_agent++
|
|
if (session.uses_mcp) result.sessions_using_mcp++
|
|
if (session.uses_web_search) result.sessions_using_web_search++
|
|
if (session.uses_web_fetch) result.sessions_using_web_fetch++
|
|
|
|
// Additional stats aggregation
|
|
result.total_lines_added += session.lines_added
|
|
result.total_lines_removed += session.lines_removed
|
|
result.total_files_modified += session.files_modified
|
|
allMessageHours.push(...session.message_hours)
|
|
|
|
for (const [tool, count] of Object.entries(session.tool_counts)) {
|
|
result.tool_counts[tool] = (result.tool_counts[tool] || 0) + count
|
|
}
|
|
|
|
for (const [lang, count] of Object.entries(session.languages)) {
|
|
result.languages[lang] = (result.languages[lang] || 0) + count
|
|
}
|
|
|
|
if (session.project_path) {
|
|
result.projects[session.project_path] =
|
|
(result.projects[session.project_path] || 0) + 1
|
|
}
|
|
|
|
const sessionFacets = facets.get(session.session_id)
|
|
if (sessionFacets) {
|
|
// Goal categories
|
|
for (const [cat, count] of safeEntries(sessionFacets.goal_categories)) {
|
|
if (count > 0) {
|
|
result.goal_categories[cat] =
|
|
(result.goal_categories[cat] || 0) + count
|
|
}
|
|
}
|
|
|
|
// Outcomes
|
|
result.outcomes[sessionFacets.outcome] =
|
|
(result.outcomes[sessionFacets.outcome] || 0) + 1
|
|
|
|
// Satisfaction counts
|
|
for (const [level, count] of safeEntries(
|
|
sessionFacets.user_satisfaction_counts,
|
|
)) {
|
|
if (count > 0) {
|
|
result.satisfaction[level] = (result.satisfaction[level] || 0) + count
|
|
}
|
|
}
|
|
|
|
// Helpfulness
|
|
result.helpfulness[sessionFacets.claude_helpfulness] =
|
|
(result.helpfulness[sessionFacets.claude_helpfulness] || 0) + 1
|
|
|
|
// Session types
|
|
result.session_types[sessionFacets.session_type] =
|
|
(result.session_types[sessionFacets.session_type] || 0) + 1
|
|
|
|
// Friction counts
|
|
for (const [type, count] of safeEntries(sessionFacets.friction_counts)) {
|
|
if (count > 0) {
|
|
result.friction[type] = (result.friction[type] || 0) + count
|
|
}
|
|
}
|
|
|
|
// Success factors
|
|
if (sessionFacets.primary_success !== 'none') {
|
|
result.success[sessionFacets.primary_success] =
|
|
(result.success[sessionFacets.primary_success] || 0) + 1
|
|
}
|
|
}
|
|
|
|
if (result.session_summaries.length < 50) {
|
|
result.session_summaries.push({
|
|
id: session.session_id.slice(0, 8),
|
|
date: session.start_time.split('T')[0] || '',
|
|
summary: session.summary || session.first_prompt.slice(0, 100),
|
|
goal: sessionFacets?.underlying_goal,
|
|
})
|
|
}
|
|
}
|
|
|
|
dates.sort()
|
|
result.date_range.start = dates[0]?.split('T')[0] || ''
|
|
result.date_range.end = dates[dates.length - 1]?.split('T')[0] || ''
|
|
|
|
// Calculate response time stats
|
|
result.user_response_times = allResponseTimes
|
|
if (allResponseTimes.length > 0) {
|
|
const sorted = [...allResponseTimes].sort((a, b) => a - b)
|
|
result.median_response_time = sorted[Math.floor(sorted.length / 2)] || 0
|
|
result.avg_response_time =
|
|
allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length
|
|
}
|
|
|
|
// Calculate days active and messages per day
|
|
const uniqueDays = new Set(dates.map(d => d.split('T')[0]))
|
|
result.days_active = uniqueDays.size
|
|
result.messages_per_day =
|
|
result.days_active > 0
|
|
? Math.round((result.total_messages / result.days_active) * 10) / 10
|
|
: 0
|
|
|
|
// Store message hours for time-of-day chart
|
|
result.message_hours = allMessageHours
|
|
|
|
result.multi_clauding = detectMultiClauding(sessions)
|
|
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Parallel Insights Generation (6 sections)
|
|
// ============================================================================
|
|
|
|
type InsightSection = {
|
|
name: string
|
|
prompt: string
|
|
maxTokens: number
|
|
}
|
|
|
|
// Sections that run in parallel first
|
|
const INSIGHT_SECTIONS: InsightSection[] = [
|
|
{
|
|
name: 'project_areas',
|
|
prompt: `Analyze this Claude Code usage data and identify project areas.
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"areas": [
|
|
{"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how Claude Code was used."}
|
|
]
|
|
}
|
|
|
|
Include 4-5 areas. Skip internal CC operations.`,
|
|
maxTokens: 8192,
|
|
},
|
|
{
|
|
name: 'interaction_style',
|
|
prompt: `Analyze this Claude Code usage data and describe the user's interaction style.
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"narrative": "2-3 paragraphs analyzing HOW the user interacts with Claude Code. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let Claude run? Include specific examples. Use **bold** for key insights.",
|
|
"key_pattern": "One sentence summary of most distinctive interaction style"
|
|
}`,
|
|
maxTokens: 8192,
|
|
},
|
|
{
|
|
name: 'what_works',
|
|
prompt: `Analyze this Claude Code usage data and identify what's working well for this user. Use second person ("you").
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"intro": "1 sentence of context",
|
|
"impressive_workflows": [
|
|
{"title": "Short title (3-6 words)", "description": "2-3 sentences describing the impressive workflow or approach. Use 'you' not 'the user'."}
|
|
]
|
|
}
|
|
|
|
Include 3 impressive workflows.`,
|
|
maxTokens: 8192,
|
|
},
|
|
{
|
|
name: 'friction_analysis',
|
|
prompt: `Analyze this Claude Code usage data and identify friction points for this user. Use second person ("you").
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"intro": "1 sentence summarizing friction patterns",
|
|
"categories": [
|
|
{"category": "Concrete category name", "description": "1-2 sentences explaining this category and what could be done differently. Use 'you' not 'the user'.", "examples": ["Specific example with consequence", "Another example"]}
|
|
]
|
|
}
|
|
|
|
Include 3 friction categories with 2 examples each.`,
|
|
maxTokens: 8192,
|
|
},
|
|
{
|
|
name: 'suggestions',
|
|
prompt: `Analyze this Claude Code usage data and suggest improvements.
|
|
|
|
## CC FEATURES REFERENCE (pick from these for features_to_try):
|
|
1. **MCP Servers**: Connect Claude to external tools, databases, and APIs via Model Context Protocol.
|
|
- How to use: Run \`claude mcp add <server-name> -- <command>\`
|
|
- Good for: database queries, Slack integration, GitHub issue lookup, connecting to internal APIs
|
|
|
|
2. **Custom Skills**: Reusable prompts you define as markdown files that run with a single /command.
|
|
- How to use: Create \`.claude/skills/commit/SKILL.md\` with instructions. Then type \`/commit\` to run it.
|
|
- Good for: repetitive workflows - /commit, /review, /test, /deploy, /pr, or complex multi-step workflows
|
|
|
|
3. **Hooks**: Shell commands that auto-run at specific lifecycle events.
|
|
- How to use: Add to \`.claude/settings.json\` under "hooks" key.
|
|
- Good for: auto-formatting code, running type checks, enforcing conventions
|
|
|
|
4. **Headless Mode**: Run Claude non-interactively from scripts and CI/CD.
|
|
- How to use: \`claude -p "fix lint errors" --allowedTools "Edit,Read,Bash"\`
|
|
- Good for: CI/CD integration, batch code fixes, automated reviews
|
|
|
|
5. **Task Agents**: Claude spawns focused sub-agents for complex exploration or parallel work.
|
|
- How to use: Claude auto-invokes when helpful, or ask "use an agent to explore X"
|
|
- Good for: codebase exploration, understanding complex systems
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"claude_md_additions": [
|
|
{"addition": "A specific line or block to add to CLAUDE.md based on workflow patterns. E.g., 'Always run tests after modifying auth-related files'", "why": "1 sentence explaining why this would help based on actual sessions", "prompt_scaffold": "Instructions for where to add this in CLAUDE.md. E.g., 'Add under ## Testing section'"}
|
|
],
|
|
"features_to_try": [
|
|
{"feature": "Feature name from CC FEATURES REFERENCE above", "one_liner": "What it does", "why_for_you": "Why this would help YOU based on your sessions", "example_code": "Actual command or config to copy"}
|
|
],
|
|
"usage_patterns": [
|
|
{"title": "Short title", "suggestion": "1-2 sentence summary", "detail": "3-4 sentences explaining how this applies to YOUR work", "copyable_prompt": "A specific prompt to copy and try"}
|
|
]
|
|
}
|
|
|
|
IMPORTANT for claude_md_additions: PRIORITIZE instructions that appear MULTIPLE TIMES in the user data. If user told Claude the same thing in 2+ sessions (e.g., 'always run tests', 'use TypeScript'), that's a PRIME candidate - they shouldn't have to repeat themselves.
|
|
|
|
IMPORTANT for features_to_try: Pick 2-3 from the CC FEATURES REFERENCE above. Include 2-3 items for each category.`,
|
|
maxTokens: 8192,
|
|
},
|
|
{
|
|
name: 'on_the_horizon',
|
|
prompt: `Analyze this Claude Code usage data and identify future opportunities.
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"intro": "1 sentence about evolving AI-assisted development",
|
|
"opportunities": [
|
|
{"title": "Short title (4-8 words)", "whats_possible": "2-3 ambitious sentences about autonomous workflows", "how_to_try": "1-2 sentences mentioning relevant tooling", "copyable_prompt": "Detailed prompt to try"}
|
|
]
|
|
}
|
|
|
|
Include 3 opportunities. Think BIG - autonomous workflows, parallel agents, iterating against tests.`,
|
|
maxTokens: 8192,
|
|
},
|
|
...(process.env.USER_TYPE === 'ant'
|
|
? [
|
|
{
|
|
name: 'cc_team_improvements',
|
|
prompt: `Analyze this Claude Code usage data and suggest product improvements for the CC team.
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"improvements": [
|
|
{"title": "Product/tooling improvement", "detail": "3-4 sentences describing the improvement", "evidence": "3-4 sentences with specific session examples"}
|
|
]
|
|
}
|
|
|
|
Include 2-3 improvements based on friction patterns observed.`,
|
|
maxTokens: 8192,
|
|
},
|
|
{
|
|
name: 'model_behavior_improvements',
|
|
prompt: `Analyze this Claude Code usage data and suggest model behavior improvements.
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"improvements": [
|
|
{"title": "Model behavior change", "detail": "3-4 sentences describing what the model should do differently", "evidence": "3-4 sentences with specific examples"}
|
|
]
|
|
}
|
|
|
|
Include 2-3 improvements based on friction patterns observed.`,
|
|
maxTokens: 8192,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
name: 'fun_ending',
|
|
prompt: `Analyze this Claude Code usage data and find a memorable moment.
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"headline": "A memorable QUALITATIVE moment from the transcripts - not a statistic. Something human, funny, or surprising.",
|
|
"detail": "Brief context about when/where this happened"
|
|
}
|
|
|
|
Find something genuinely interesting or amusing from the session summaries.`,
|
|
maxTokens: 8192,
|
|
},
|
|
]
|
|
|
|
type InsightResults = {
|
|
at_a_glance?: {
|
|
whats_working?: string
|
|
whats_hindering?: string
|
|
quick_wins?: string
|
|
ambitious_workflows?: string
|
|
}
|
|
project_areas?: {
|
|
areas?: Array<{ name: string; session_count: number; description: string }>
|
|
}
|
|
interaction_style?: {
|
|
narrative?: string
|
|
key_pattern?: string
|
|
}
|
|
what_works?: {
|
|
intro?: string
|
|
impressive_workflows?: Array<{ title: string; description: string }>
|
|
}
|
|
friction_analysis?: {
|
|
intro?: string
|
|
categories?: Array<{
|
|
category: string
|
|
description: string
|
|
examples?: string[]
|
|
}>
|
|
}
|
|
suggestions?: {
|
|
claude_md_additions?: Array<{
|
|
addition: string
|
|
why: string
|
|
where?: string
|
|
prompt_scaffold?: string
|
|
}>
|
|
features_to_try?: Array<{
|
|
feature: string
|
|
one_liner: string
|
|
why_for_you: string
|
|
example_code?: string
|
|
}>
|
|
usage_patterns?: Array<{
|
|
title: string
|
|
suggestion: string
|
|
detail?: string
|
|
copyable_prompt?: string
|
|
}>
|
|
}
|
|
on_the_horizon?: {
|
|
intro?: string
|
|
opportunities?: Array<{
|
|
title: string
|
|
whats_possible: string
|
|
how_to_try?: string
|
|
copyable_prompt?: string
|
|
}>
|
|
}
|
|
cc_team_improvements?: {
|
|
improvements?: Array<{
|
|
title: string
|
|
detail: string
|
|
evidence?: string
|
|
}>
|
|
}
|
|
model_behavior_improvements?: {
|
|
improvements?: Array<{
|
|
title: string
|
|
detail: string
|
|
evidence?: string
|
|
}>
|
|
}
|
|
fun_ending?: {
|
|
headline?: string
|
|
detail?: string
|
|
}
|
|
}
|
|
|
|
async function generateSectionInsight(
|
|
section: InsightSection,
|
|
dataContext: string,
|
|
): Promise<{ name: string; result: unknown }> {
|
|
try {
|
|
const result = await queryWithModel({
|
|
systemPrompt: asSystemPrompt([]),
|
|
userPrompt: section.prompt + '\n\nDATA:\n' + dataContext,
|
|
signal: new AbortController().signal,
|
|
options: {
|
|
model: getInsightsModel(),
|
|
querySource: 'insights',
|
|
agents: [],
|
|
isNonInteractiveSession: true,
|
|
hasAppendSystemPrompt: false,
|
|
mcpTools: [],
|
|
maxOutputTokensOverride: section.maxTokens,
|
|
},
|
|
})
|
|
|
|
const text = extractTextContent(
|
|
result.message.content as readonly { readonly type: string }[],
|
|
)
|
|
|
|
if (text) {
|
|
// Parse JSON from response
|
|
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
|
if (jsonMatch) {
|
|
try {
|
|
return { name: section.name, result: jsonParse(jsonMatch[0]) }
|
|
} catch {
|
|
return { name: section.name, result: null }
|
|
}
|
|
}
|
|
}
|
|
return { name: section.name, result: null }
|
|
} catch (err) {
|
|
logError(new Error(`${section.name} failed: ${toError(err).message}`))
|
|
return { name: section.name, result: null }
|
|
}
|
|
}
|
|
|
|
async function generateParallelInsights(
|
|
data: AggregatedData,
|
|
facets: Map<string, SessionFacets>,
|
|
): Promise<InsightResults> {
|
|
// Build data context string
|
|
const facetSummaries = Array.from(facets.values())
|
|
.slice(0, 50)
|
|
.map(f => `- ${f.brief_summary} (${f.outcome}, ${f.claude_helpfulness})`)
|
|
.join('\n')
|
|
|
|
const frictionDetails = Array.from(facets.values())
|
|
.filter(f => f.friction_detail)
|
|
.slice(0, 20)
|
|
.map(f => `- ${f.friction_detail}`)
|
|
.join('\n')
|
|
|
|
const userInstructions = Array.from(facets.values())
|
|
.flatMap(f => f.user_instructions_to_claude || [])
|
|
.slice(0, 15)
|
|
.map(i => `- ${i}`)
|
|
.join('\n')
|
|
|
|
const dataContext = jsonStringify(
|
|
{
|
|
sessions: data.total_sessions,
|
|
analyzed: data.sessions_with_facets,
|
|
date_range: data.date_range,
|
|
messages: data.total_messages,
|
|
hours: Math.round(data.total_duration_hours),
|
|
commits: data.git_commits,
|
|
top_tools: Object.entries(data.tool_counts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 8),
|
|
top_goals: Object.entries(data.goal_categories)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 8),
|
|
outcomes: data.outcomes,
|
|
satisfaction: data.satisfaction,
|
|
friction: data.friction,
|
|
success: data.success,
|
|
languages: data.languages,
|
|
},
|
|
null,
|
|
2,
|
|
)
|
|
|
|
const fullContext =
|
|
dataContext +
|
|
'\n\nSESSION SUMMARIES:\n' +
|
|
facetSummaries +
|
|
'\n\nFRICTION DETAILS:\n' +
|
|
frictionDetails +
|
|
'\n\nUSER INSTRUCTIONS TO CLAUDE:\n' +
|
|
(userInstructions || 'None captured')
|
|
|
|
// Run sections in parallel first (excluding at_a_glance)
|
|
const results = await Promise.all(
|
|
INSIGHT_SECTIONS.map(section =>
|
|
generateSectionInsight(section, fullContext),
|
|
),
|
|
)
|
|
|
|
// Combine results
|
|
const insights: InsightResults = {}
|
|
for (const { name, result } of results) {
|
|
if (result) {
|
|
;(insights as Record<string, unknown>)[name] = result
|
|
}
|
|
}
|
|
|
|
// Build rich context from generated sections for At a Glance
|
|
const projectAreasText =
|
|
(
|
|
insights.project_areas as {
|
|
areas?: Array<{ name: string; description: string }>
|
|
}
|
|
)?.areas
|
|
?.map(a => `- ${a.name}: ${a.description}`)
|
|
.join('\n') || ''
|
|
|
|
const bigWinsText =
|
|
(
|
|
insights.what_works as {
|
|
impressive_workflows?: Array<{ title: string; description: string }>
|
|
}
|
|
)?.impressive_workflows
|
|
?.map(w => `- ${w.title}: ${w.description}`)
|
|
.join('\n') || ''
|
|
|
|
const frictionText =
|
|
(
|
|
insights.friction_analysis as {
|
|
categories?: Array<{ category: string; description: string }>
|
|
}
|
|
)?.categories
|
|
?.map(c => `- ${c.category}: ${c.description}`)
|
|
.join('\n') || ''
|
|
|
|
const featuresText =
|
|
(
|
|
insights.suggestions as {
|
|
features_to_try?: Array<{ feature: string; one_liner: string }>
|
|
}
|
|
)?.features_to_try
|
|
?.map(f => `- ${f.feature}: ${f.one_liner}`)
|
|
.join('\n') || ''
|
|
|
|
const patternsText =
|
|
(
|
|
insights.suggestions as {
|
|
usage_patterns?: Array<{ title: string; suggestion: string }>
|
|
}
|
|
)?.usage_patterns
|
|
?.map(p => `- ${p.title}: ${p.suggestion}`)
|
|
.join('\n') || ''
|
|
|
|
const horizonText =
|
|
(
|
|
insights.on_the_horizon as {
|
|
opportunities?: Array<{ title: string; whats_possible: string }>
|
|
}
|
|
)?.opportunities
|
|
?.map(o => `- ${o.title}: ${o.whats_possible}`)
|
|
.join('\n') || ''
|
|
|
|
// Now generate "At a Glance" with access to other sections' outputs
|
|
const atAGlancePrompt = `You're writing an "At a Glance" summary for a Claude Code usage insights report for Claude Code users. The goal is to help them understand their usage and improve how they can use Claude better, especially as models improve.
|
|
|
|
Use this 4-part structure:
|
|
|
|
1. **What's working** - What is the user's unique style of interacting with Claude and what are some impactful things they've done? You can include one or two details, but keep it high level since things might not be fresh in the user's memory. Don't be fluffy or overly complimentary. Also, don't focus on the tool calls they use.
|
|
|
|
2. **What's hindering you** - Split into (a) Claude's fault (misunderstandings, wrong approaches, bugs) and (b) user-side friction (not providing enough context, environment issues -- ideally more general than just one project). Be honest but constructive.
|
|
|
|
3. **Quick wins to try** - Specific Claude Code features they could try from the examples below, or a workflow technique if you think it's really compelling. (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out more context up front" which are less compelling.)
|
|
|
|
4. **Ambitious workflows for better models** - As we move to much more capable models over the next 3-6 months, what should they prepare for? What workflows that seem impossible now will become possible? Draw from the appropriate section below.
|
|
|
|
Keep each section to 2-3 not-too-long sentences. Don't overwhelm the user. Don't mention specific numerical stats or underlined_categories from the session data below. Use a coaching tone.
|
|
|
|
RESPOND WITH ONLY A VALID JSON OBJECT:
|
|
{
|
|
"whats_working": "(refer to instructions above)",
|
|
"whats_hindering": "(refer to instructions above)",
|
|
"quick_wins": "(refer to instructions above)",
|
|
"ambitious_workflows": "(refer to instructions above)"
|
|
}
|
|
|
|
SESSION DATA:
|
|
${fullContext}
|
|
|
|
## Project Areas (what user works on)
|
|
${projectAreasText}
|
|
|
|
## Big Wins (impressive accomplishments)
|
|
${bigWinsText}
|
|
|
|
## Friction Categories (where things go wrong)
|
|
${frictionText}
|
|
|
|
## Features to Try
|
|
${featuresText}
|
|
|
|
## Usage Patterns to Adopt
|
|
${patternsText}
|
|
|
|
## On the Horizon (ambitious workflows for better models)
|
|
${horizonText}`
|
|
|
|
const atAGlanceSection: InsightSection = {
|
|
name: 'at_a_glance',
|
|
prompt: atAGlancePrompt,
|
|
maxTokens: 8192,
|
|
}
|
|
|
|
const atAGlanceResult = await generateSectionInsight(atAGlanceSection, '')
|
|
if (atAGlanceResult.result) {
|
|
insights.at_a_glance = atAGlanceResult.result as {
|
|
whats_working?: string
|
|
whats_hindering?: string
|
|
quick_wins?: string
|
|
ambitious_workflows?: string
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// Escape HTML but render **bold** as <strong>
|
|
function escapeHtmlWithBold(text: string): string {
|
|
const escaped = escapeHtml(text)
|
|
return escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
}
|
|
|
|
// Fixed orderings for specific charts (matching Python reference)
|
|
const SATISFACTION_ORDER = [
|
|
'frustrated',
|
|
'dissatisfied',
|
|
'likely_satisfied',
|
|
'satisfied',
|
|
'happy',
|
|
'unsure',
|
|
]
|
|
|
|
const OUTCOME_ORDER = [
|
|
'not_achieved',
|
|
'partially_achieved',
|
|
'mostly_achieved',
|
|
'fully_achieved',
|
|
'unclear_from_transcript',
|
|
]
|
|
|
|
function generateBarChart(
|
|
data: Record<string, number>,
|
|
color: string,
|
|
maxItems = 6,
|
|
fixedOrder?: string[],
|
|
): string {
|
|
let entries: [string, number][]
|
|
|
|
if (fixedOrder) {
|
|
// Use fixed order, only including items that exist in data
|
|
entries = fixedOrder
|
|
.filter(key => key in data && (data[key] ?? 0) > 0)
|
|
.map(key => [key, data[key] ?? 0] as [string, number])
|
|
} else {
|
|
// Sort by count descending
|
|
entries = Object.entries(data)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, maxItems)
|
|
}
|
|
|
|
if (entries.length === 0) return '<p class="empty">No data</p>'
|
|
|
|
const maxVal = Math.max(...entries.map(e => e[1]))
|
|
return entries
|
|
.map(([label, count]) => {
|
|
const pct = (count / maxVal) * 100
|
|
// Use LABEL_MAP if available, otherwise clean up underscores and title case
|
|
const cleanLabel =
|
|
LABEL_MAP[label] ||
|
|
label.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
return `<div class="bar-row">
|
|
<div class="bar-label">${escapeHtml(cleanLabel)}</div>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
|
<div class="bar-value">${count}</div>
|
|
</div>`
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
function generateResponseTimeHistogram(times: number[]): string {
|
|
if (times.length === 0) return '<p class="empty">No response time data</p>'
|
|
|
|
// Create buckets (matching Python reference)
|
|
const buckets: Record<string, number> = {
|
|
'2-10s': 0,
|
|
'10-30s': 0,
|
|
'30s-1m': 0,
|
|
'1-2m': 0,
|
|
'2-5m': 0,
|
|
'5-15m': 0,
|
|
'>15m': 0,
|
|
}
|
|
|
|
for (const t of times) {
|
|
if (t < 10) buckets['2-10s'] = (buckets['2-10s'] ?? 0) + 1
|
|
else if (t < 30) buckets['10-30s'] = (buckets['10-30s'] ?? 0) + 1
|
|
else if (t < 60) buckets['30s-1m'] = (buckets['30s-1m'] ?? 0) + 1
|
|
else if (t < 120) buckets['1-2m'] = (buckets['1-2m'] ?? 0) + 1
|
|
else if (t < 300) buckets['2-5m'] = (buckets['2-5m'] ?? 0) + 1
|
|
else if (t < 900) buckets['5-15m'] = (buckets['5-15m'] ?? 0) + 1
|
|
else buckets['>15m'] = (buckets['>15m'] ?? 0) + 1
|
|
}
|
|
|
|
const maxVal = Math.max(...Object.values(buckets))
|
|
if (maxVal === 0) return '<p class="empty">No response time data</p>'
|
|
|
|
return Object.entries(buckets)
|
|
.map(([label, count]) => {
|
|
const pct = (count / maxVal) * 100
|
|
return `<div class="bar-row">
|
|
<div class="bar-label">${label}</div>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:#6366f1"></div></div>
|
|
<div class="bar-value">${count}</div>
|
|
</div>`
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
function generateTimeOfDayChart(messageHours: number[]): string {
|
|
if (messageHours.length === 0) return '<p class="empty">No time data</p>'
|
|
|
|
// Group into time periods
|
|
const periods = [
|
|
{ label: 'Morning (6-12)', range: [6, 7, 8, 9, 10, 11] },
|
|
{ label: 'Afternoon (12-18)', range: [12, 13, 14, 15, 16, 17] },
|
|
{ label: 'Evening (18-24)', range: [18, 19, 20, 21, 22, 23] },
|
|
{ label: 'Night (0-6)', range: [0, 1, 2, 3, 4, 5] },
|
|
]
|
|
|
|
const hourCounts: Record<number, number> = {}
|
|
for (const h of messageHours) {
|
|
hourCounts[h] = (hourCounts[h] || 0) + 1
|
|
}
|
|
|
|
const periodCounts = periods.map(p => ({
|
|
label: p.label,
|
|
count: p.range.reduce((sum, h) => sum + (hourCounts[h] || 0), 0),
|
|
}))
|
|
|
|
const maxVal = Math.max(...periodCounts.map(p => p.count)) || 1
|
|
|
|
const barsHtml = periodCounts
|
|
.map(
|
|
p => `
|
|
<div class="bar-row">
|
|
<div class="bar-label">${p.label}</div>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${(p.count / maxVal) * 100}%;background:#8b5cf6"></div></div>
|
|
<div class="bar-value">${p.count}</div>
|
|
</div>`,
|
|
)
|
|
.join('\n')
|
|
|
|
return `<div id="hour-histogram">${barsHtml}</div>`
|
|
}
|
|
|
|
function getHourCountsJson(messageHours: number[]): string {
|
|
const hourCounts: Record<number, number> = {}
|
|
for (const h of messageHours) {
|
|
hourCounts[h] = (hourCounts[h] || 0) + 1
|
|
}
|
|
return jsonStringify(hourCounts)
|
|
}
|
|
|
|
function generateHtmlReport(
|
|
data: AggregatedData,
|
|
insights: InsightResults,
|
|
): string {
|
|
const markdownToHtml = (md: string): string => {
|
|
if (!md) return ''
|
|
return md
|
|
.split('\n\n')
|
|
.map(p => {
|
|
let html = escapeHtml(p)
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
html = html.replace(/^- /gm, '• ')
|
|
html = html.replace(/\n/g, '<br>')
|
|
return `<p>${html}</p>`
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
// Build At a Glance section (new 4-part format with links to sections)
|
|
const atAGlance = insights.at_a_glance
|
|
const atAGlanceHtml = atAGlance
|
|
? `
|
|
<div class="at-a-glance">
|
|
<div class="glance-title">At a Glance</div>
|
|
<div class="glance-sections">
|
|
${atAGlance.whats_working ? `<div class="glance-section"><strong>What's working:</strong> ${escapeHtmlWithBold(atAGlance.whats_working)} <a href="#section-wins" class="see-more">Impressive Things You Did →</a></div>` : ''}
|
|
${atAGlance.whats_hindering ? `<div class="glance-section"><strong>What's hindering you:</strong> ${escapeHtmlWithBold(atAGlance.whats_hindering)} <a href="#section-friction" class="see-more">Where Things Go Wrong →</a></div>` : ''}
|
|
${atAGlance.quick_wins ? `<div class="glance-section"><strong>Quick wins to try:</strong> ${escapeHtmlWithBold(atAGlance.quick_wins)} <a href="#section-features" class="see-more">Features to Try →</a></div>` : ''}
|
|
${atAGlance.ambitious_workflows ? `<div class="glance-section"><strong>Ambitious workflows:</strong> ${escapeHtmlWithBold(atAGlance.ambitious_workflows)} <a href="#section-horizon" class="see-more">On the Horizon →</a></div>` : ''}
|
|
</div>
|
|
</div>
|
|
`
|
|
: ''
|
|
|
|
// Build project areas section
|
|
const projectAreas = insights.project_areas?.areas || []
|
|
const projectAreasHtml =
|
|
projectAreas.length > 0
|
|
? `
|
|
<h2 id="section-work">What You Work On</h2>
|
|
<div class="project-areas">
|
|
${projectAreas
|
|
.map(
|
|
area => `
|
|
<div class="project-area">
|
|
<div class="area-header">
|
|
<span class="area-name">${escapeHtml(area.name)}</span>
|
|
<span class="area-count">~${area.session_count} sessions</span>
|
|
</div>
|
|
<div class="area-desc">${escapeHtml(area.description)}</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
: ''
|
|
|
|
// Build interaction style section
|
|
const interactionStyle = insights.interaction_style
|
|
const interactionHtml = interactionStyle?.narrative
|
|
? `
|
|
<h2 id="section-usage">How You Use Claude Code</h2>
|
|
<div class="narrative">
|
|
${markdownToHtml(interactionStyle.narrative)}
|
|
${interactionStyle.key_pattern ? `<div class="key-insight"><strong>Key pattern:</strong> ${escapeHtml(interactionStyle.key_pattern)}</div>` : ''}
|
|
</div>
|
|
`
|
|
: ''
|
|
|
|
// Build what works section
|
|
const whatWorks = insights.what_works
|
|
const whatWorksHtml =
|
|
whatWorks?.impressive_workflows && whatWorks.impressive_workflows.length > 0
|
|
? `
|
|
<h2 id="section-wins">Impressive Things You Did</h2>
|
|
${whatWorks.intro ? `<p class="section-intro">${escapeHtml(whatWorks.intro)}</p>` : ''}
|
|
<div class="big-wins">
|
|
${whatWorks.impressive_workflows
|
|
.map(
|
|
wf => `
|
|
<div class="big-win">
|
|
<div class="big-win-title">${escapeHtml(wf.title || '')}</div>
|
|
<div class="big-win-desc">${escapeHtml(wf.description || '')}</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
: ''
|
|
|
|
// Build friction section
|
|
const frictionAnalysis = insights.friction_analysis
|
|
const frictionHtml =
|
|
frictionAnalysis?.categories && frictionAnalysis.categories.length > 0
|
|
? `
|
|
<h2 id="section-friction">Where Things Go Wrong</h2>
|
|
${frictionAnalysis.intro ? `<p class="section-intro">${escapeHtml(frictionAnalysis.intro)}</p>` : ''}
|
|
<div class="friction-categories">
|
|
${frictionAnalysis.categories
|
|
.map(
|
|
cat => `
|
|
<div class="friction-category">
|
|
<div class="friction-title">${escapeHtml(cat.category || '')}</div>
|
|
<div class="friction-desc">${escapeHtml(cat.description || '')}</div>
|
|
${cat.examples ? `<ul class="friction-examples">${cat.examples.map(ex => `<li>${escapeHtml(ex)}</li>`).join('')}</ul>` : ''}
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
: ''
|
|
|
|
// Build suggestions section
|
|
const suggestions = insights.suggestions
|
|
const suggestionsHtml = suggestions
|
|
? `
|
|
${
|
|
suggestions.claude_md_additions &&
|
|
suggestions.claude_md_additions.length > 0
|
|
? `
|
|
<h2 id="section-features">Existing CC Features to Try</h2>
|
|
<div class="claude-md-section">
|
|
<h3>Suggested CLAUDE.md Additions</h3>
|
|
<p style="font-size: 12px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code to add it to your CLAUDE.md.</p>
|
|
<div class="claude-md-actions">
|
|
<button class="copy-all-btn" onclick="copyAllCheckedClaudeMd()">Copy All Checked</button>
|
|
</div>
|
|
${suggestions.claude_md_additions
|
|
.map(
|
|
(add, i) => `
|
|
<div class="claude-md-item">
|
|
<input type="checkbox" id="cmd-${i}" class="cmd-checkbox" checked data-text="${escapeHtml(add.prompt_scaffold || add.where || 'Add to CLAUDE.md')}\\n\\n${escapeHtml(add.addition)}">
|
|
<label for="cmd-${i}">
|
|
<code class="cmd-code">${escapeHtml(add.addition)}</code>
|
|
<button class="copy-btn" onclick="copyCmdItem(${i})">Copy</button>
|
|
</label>
|
|
<div class="cmd-why">${escapeHtml(add.why)}</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
: ''
|
|
}
|
|
${
|
|
suggestions.features_to_try && suggestions.features_to_try.length > 0
|
|
? `
|
|
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll set it up for you.</p>
|
|
<div class="features-section">
|
|
${suggestions.features_to_try
|
|
.map(
|
|
feat => `
|
|
<div class="feature-card">
|
|
<div class="feature-title">${escapeHtml(feat.feature || '')}</div>
|
|
<div class="feature-oneliner">${escapeHtml(feat.one_liner || '')}</div>
|
|
<div class="feature-why"><strong>Why for you:</strong> ${escapeHtml(feat.why_for_you || '')}</div>
|
|
${
|
|
feat.example_code
|
|
? `
|
|
<div class="feature-examples">
|
|
<div class="feature-example">
|
|
<div class="example-code-row">
|
|
<code class="example-code">${escapeHtml(feat.example_code)}</code>
|
|
<button class="copy-btn" onclick="copyText(this)">Copy</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ''
|
|
}
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
: ''
|
|
}
|
|
${
|
|
suggestions.usage_patterns && suggestions.usage_patterns.length > 0
|
|
? `
|
|
<h2 id="section-patterns">New Ways to Use Claude Code</h2>
|
|
<p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll walk you through it.</p>
|
|
<div class="patterns-section">
|
|
${suggestions.usage_patterns
|
|
.map(
|
|
pat => `
|
|
<div class="pattern-card">
|
|
<div class="pattern-title">${escapeHtml(pat.title || '')}</div>
|
|
<div class="pattern-summary">${escapeHtml(pat.suggestion || '')}</div>
|
|
${pat.detail ? `<div class="pattern-detail">${escapeHtml(pat.detail)}</div>` : ''}
|
|
${
|
|
pat.copyable_prompt
|
|
? `
|
|
<div class="copyable-prompt-section">
|
|
<div class="prompt-label">Paste into Claude Code:</div>
|
|
<div class="copyable-prompt-row">
|
|
<code class="copyable-prompt">${escapeHtml(pat.copyable_prompt)}</code>
|
|
<button class="copy-btn" onclick="copyText(this)">Copy</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ''
|
|
}
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
: ''
|
|
}
|
|
`
|
|
: ''
|
|
|
|
// Build On the Horizon section
|
|
const horizonData = insights.on_the_horizon
|
|
const horizonHtml =
|
|
horizonData?.opportunities && horizonData.opportunities.length > 0
|
|
? `
|
|
<h2 id="section-horizon">On the Horizon</h2>
|
|
${horizonData.intro ? `<p class="section-intro">${escapeHtml(horizonData.intro)}</p>` : ''}
|
|
<div class="horizon-section">
|
|
${horizonData.opportunities
|
|
.map(
|
|
opp => `
|
|
<div class="horizon-card">
|
|
<div class="horizon-title">${escapeHtml(opp.title || '')}</div>
|
|
<div class="horizon-possible">${escapeHtml(opp.whats_possible || '')}</div>
|
|
${opp.how_to_try ? `<div class="horizon-tip"><strong>Getting started:</strong> ${escapeHtml(opp.how_to_try)}</div>` : ''}
|
|
${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into Claude Code:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ''}
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
: ''
|
|
|
|
// Build Team Feedback section (collapsible, ant-only)
|
|
const ccImprovements =
|
|
process.env.USER_TYPE === 'ant'
|
|
? insights.cc_team_improvements?.improvements || []
|
|
: []
|
|
const modelImprovements =
|
|
process.env.USER_TYPE === 'ant'
|
|
? insights.model_behavior_improvements?.improvements || []
|
|
: []
|
|
const teamFeedbackHtml =
|
|
ccImprovements.length > 0 || modelImprovements.length > 0
|
|
? `
|
|
<h2 id="section-feedback" class="feedback-header">Closing the Loop: Feedback for Other Teams</h2>
|
|
<p class="feedback-intro">Suggestions for the CC product and model teams based on your usage patterns. Click to expand.</p>
|
|
${
|
|
ccImprovements.length > 0
|
|
? `
|
|
<div class="collapsible-section">
|
|
<div class="collapsible-header" onclick="toggleCollapsible(this)">
|
|
<span class="collapsible-arrow">▶</span>
|
|
<h3>Product Improvements for CC Team</h3>
|
|
</div>
|
|
<div class="collapsible-content">
|
|
<div class="suggestions-section">
|
|
${ccImprovements
|
|
.map(
|
|
imp => `
|
|
<div class="feedback-card team-card">
|
|
<div class="feedback-title">${escapeHtml(imp.title || '')}</div>
|
|
<div class="feedback-detail">${escapeHtml(imp.detail || '')}</div>
|
|
${imp.evidence ? `<div class="feedback-evidence"><em>Evidence:</em> ${escapeHtml(imp.evidence)}</div>` : ''}
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ''
|
|
}
|
|
${
|
|
modelImprovements.length > 0
|
|
? `
|
|
<div class="collapsible-section">
|
|
<div class="collapsible-header" onclick="toggleCollapsible(this)">
|
|
<span class="collapsible-arrow">▶</span>
|
|
<h3>Model Behavior Improvements</h3>
|
|
</div>
|
|
<div class="collapsible-content">
|
|
<div class="suggestions-section">
|
|
${modelImprovements
|
|
.map(
|
|
imp => `
|
|
<div class="feedback-card model-card">
|
|
<div class="feedback-title">${escapeHtml(imp.title || '')}</div>
|
|
<div class="feedback-detail">${escapeHtml(imp.detail || '')}</div>
|
|
${imp.evidence ? `<div class="feedback-evidence"><em>Evidence:</em> ${escapeHtml(imp.evidence)}</div>` : ''}
|
|
</div>
|
|
`,
|
|
)
|
|
.join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ''
|
|
}
|
|
`
|
|
: ''
|
|
|
|
// Build Fun Ending section
|
|
const funEnding = insights.fun_ending
|
|
const funEndingHtml = funEnding?.headline
|
|
? `
|
|
<div class="fun-ending">
|
|
<div class="fun-headline">"${escapeHtml(funEnding.headline)}"</div>
|
|
${funEnding.detail ? `<div class="fun-detail">${escapeHtml(funEnding.detail)}</div>` : ''}
|
|
</div>
|
|
`
|
|
: ''
|
|
|
|
const css = `
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #334155; line-height: 1.65; padding: 48px 24px; }
|
|
.container { max-width: 800px; margin: 0 auto; }
|
|
h1 { font-size: 32px; font-weight: 700; color: #0f172a; margin-bottom: 8px; }
|
|
h2 { font-size: 20px; font-weight: 600; color: #0f172a; margin-top: 48px; margin-bottom: 16px; }
|
|
.subtitle { color: #64748b; font-size: 15px; margin-bottom: 32px; }
|
|
.nav-toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 32px 0; padding: 16px; background: white; border-radius: 8px; border: 1px solid #e2e8f0; }
|
|
.nav-toc a { font-size: 12px; color: #64748b; text-decoration: none; padding: 6px 12px; border-radius: 6px; background: #f1f5f9; transition: all 0.15s; }
|
|
.nav-toc a:hover { background: #e2e8f0; color: #334155; }
|
|
.stats-row { display: flex; gap: 24px; margin-bottom: 40px; padding: 20px 0; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0; flex-wrap: wrap; }
|
|
.stat { text-align: center; }
|
|
.stat-value { font-size: 24px; font-weight: 700; color: #0f172a; }
|
|
.stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; }
|
|
.at-a-glance { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #f59e0b; border-radius: 12px; padding: 20px 24px; margin-bottom: 32px; }
|
|
.glance-title { font-size: 16px; font-weight: 700; color: #92400e; margin-bottom: 16px; }
|
|
.glance-sections { display: flex; flex-direction: column; gap: 12px; }
|
|
.glance-section { font-size: 14px; color: #78350f; line-height: 1.6; }
|
|
.glance-section strong { color: #92400e; }
|
|
.see-more { color: #b45309; text-decoration: none; font-size: 13px; white-space: nowrap; }
|
|
.see-more:hover { text-decoration: underline; }
|
|
.project-areas { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; }
|
|
.project-area { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; }
|
|
.area-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
.area-name { font-weight: 600; font-size: 15px; color: #0f172a; }
|
|
.area-count { font-size: 12px; color: #64748b; background: #f1f5f9; padding: 2px 8px; border-radius: 4px; }
|
|
.area-desc { font-size: 14px; color: #475569; line-height: 1.5; }
|
|
.narrative { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin-bottom: 24px; }
|
|
.narrative p { margin-bottom: 12px; font-size: 14px; color: #475569; line-height: 1.7; }
|
|
.key-insight { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 12px 16px; margin-top: 12px; font-size: 14px; color: #166534; }
|
|
.section-intro { font-size: 14px; color: #64748b; margin-bottom: 16px; }
|
|
.big-wins { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; }
|
|
.big-win { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 16px; }
|
|
.big-win-title { font-weight: 600; font-size: 15px; color: #166534; margin-bottom: 8px; }
|
|
.big-win-desc { font-size: 14px; color: #15803d; line-height: 1.5; }
|
|
.friction-categories { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
|
|
.friction-category { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 8px; padding: 16px; }
|
|
.friction-title { font-weight: 600; font-size: 15px; color: #991b1b; margin-bottom: 6px; }
|
|
.friction-desc { font-size: 13px; color: #7f1d1d; margin-bottom: 10px; }
|
|
.friction-examples { margin: 0 0 0 20px; font-size: 13px; color: #334155; }
|
|
.friction-examples li { margin-bottom: 4px; }
|
|
.claude-md-section { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 16px; margin-bottom: 20px; }
|
|
.claude-md-section h3 { font-size: 14px; font-weight: 600; color: #1e40af; margin: 0 0 12px 0; }
|
|
.claude-md-actions { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #dbeafe; }
|
|
.copy-all-btn { background: #2563eb; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; cursor: pointer; font-weight: 500; transition: all 0.2s; }
|
|
.copy-all-btn:hover { background: #1d4ed8; }
|
|
.copy-all-btn.copied { background: #16a34a; }
|
|
.claude-md-item { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; padding: 10px 0; border-bottom: 1px solid #dbeafe; }
|
|
.claude-md-item:last-child { border-bottom: none; }
|
|
.cmd-checkbox { margin-top: 2px; }
|
|
.cmd-code { background: white; padding: 8px 12px; border-radius: 4px; font-size: 12px; color: #1e40af; border: 1px solid #bfdbfe; font-family: monospace; display: block; white-space: pre-wrap; word-break: break-word; flex: 1; }
|
|
.cmd-why { font-size: 12px; color: #64748b; width: 100%; padding-left: 24px; margin-top: 4px; }
|
|
.features-section, .patterns-section { display: flex; flex-direction: column; gap: 12px; margin: 16px 0; }
|
|
.feature-card { background: #f0fdf4; border: 1px solid #86efac; border-radius: 8px; padding: 16px; }
|
|
.pattern-card { background: #f0f9ff; border: 1px solid #7dd3fc; border-radius: 8px; padding: 16px; }
|
|
.feature-title, .pattern-title { font-weight: 600; font-size: 15px; color: #0f172a; margin-bottom: 6px; }
|
|
.feature-oneliner { font-size: 14px; color: #475569; margin-bottom: 8px; }
|
|
.pattern-summary { font-size: 14px; color: #475569; margin-bottom: 8px; }
|
|
.feature-why, .pattern-detail { font-size: 13px; color: #334155; line-height: 1.5; }
|
|
.feature-examples { margin-top: 12px; }
|
|
.feature-example { padding: 8px 0; border-top: 1px solid #d1fae5; }
|
|
.feature-example:first-child { border-top: none; }
|
|
.example-desc { font-size: 13px; color: #334155; margin-bottom: 6px; }
|
|
.example-code-row { display: flex; align-items: flex-start; gap: 8px; }
|
|
.example-code { flex: 1; background: #f1f5f9; padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: #334155; overflow-x: auto; white-space: pre-wrap; }
|
|
.copyable-prompt-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; }
|
|
.copyable-prompt-row { display: flex; align-items: flex-start; gap: 8px; }
|
|
.copyable-prompt { flex: 1; background: #f8fafc; padding: 10px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: #334155; border: 1px solid #e2e8f0; white-space: pre-wrap; line-height: 1.5; }
|
|
.feature-code { background: #f8fafc; padding: 12px; border-radius: 6px; margin-top: 12px; border: 1px solid #e2e8f0; display: flex; align-items: flex-start; gap: 8px; }
|
|
.feature-code code { flex: 1; font-family: monospace; font-size: 12px; color: #334155; white-space: pre-wrap; }
|
|
.pattern-prompt { background: #f8fafc; padding: 12px; border-radius: 6px; margin-top: 12px; border: 1px solid #e2e8f0; }
|
|
.pattern-prompt code { font-family: monospace; font-size: 12px; color: #334155; display: block; white-space: pre-wrap; margin-bottom: 8px; }
|
|
.prompt-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #64748b; margin-bottom: 6px; }
|
|
.copy-btn { background: #e2e8f0; border: none; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; color: #475569; flex-shrink: 0; }
|
|
.copy-btn:hover { background: #cbd5e1; }
|
|
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 24px 0; }
|
|
.chart-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; }
|
|
.chart-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 12px; }
|
|
.bar-row { display: flex; align-items: center; margin-bottom: 6px; }
|
|
.bar-label { width: 100px; font-size: 11px; color: #475569; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.bar-track { flex: 1; height: 6px; background: #f1f5f9; border-radius: 3px; margin: 0 8px; }
|
|
.bar-fill { height: 100%; border-radius: 3px; }
|
|
.bar-value { width: 28px; font-size: 11px; font-weight: 500; color: #64748b; text-align: right; }
|
|
.empty { color: #94a3b8; font-size: 13px; }
|
|
.horizon-section { display: flex; flex-direction: column; gap: 16px; }
|
|
.horizon-card { background: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%); border: 1px solid #c4b5fd; border-radius: 8px; padding: 16px; }
|
|
.horizon-title { font-weight: 600; font-size: 15px; color: #5b21b6; margin-bottom: 8px; }
|
|
.horizon-possible { font-size: 14px; color: #334155; margin-bottom: 10px; line-height: 1.5; }
|
|
.horizon-tip { font-size: 13px; color: #6b21a8; background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: 4px; }
|
|
.feedback-header { margin-top: 48px; color: #64748b; font-size: 16px; }
|
|
.feedback-intro { font-size: 13px; color: #94a3b8; margin-bottom: 16px; }
|
|
.feedback-section { margin-top: 16px; }
|
|
.feedback-section h3 { font-size: 14px; font-weight: 600; color: #475569; margin-bottom: 12px; }
|
|
.feedback-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
|
.feedback-card.team-card { background: #eff6ff; border-color: #bfdbfe; }
|
|
.feedback-card.model-card { background: #faf5ff; border-color: #e9d5ff; }
|
|
.feedback-title { font-weight: 600; font-size: 14px; color: #0f172a; margin-bottom: 6px; }
|
|
.feedback-detail { font-size: 13px; color: #475569; line-height: 1.5; }
|
|
.feedback-evidence { font-size: 12px; color: #64748b; margin-top: 8px; }
|
|
.fun-ending { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #fbbf24; border-radius: 12px; padding: 24px; margin-top: 40px; text-align: center; }
|
|
.fun-headline { font-size: 18px; font-weight: 600; color: #78350f; margin-bottom: 8px; }
|
|
.fun-detail { font-size: 14px; color: #92400e; }
|
|
.collapsible-section { margin-top: 16px; }
|
|
.collapsible-header { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 0; border-bottom: 1px solid #e2e8f0; }
|
|
.collapsible-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: #475569; }
|
|
.collapsible-arrow { font-size: 12px; color: #94a3b8; transition: transform 0.2s; }
|
|
.collapsible-content { display: none; padding-top: 16px; }
|
|
.collapsible-content.open { display: block; }
|
|
.collapsible-header.open .collapsible-arrow { transform: rotate(90deg); }
|
|
@media (max-width: 640px) { .charts-row { grid-template-columns: 1fr; } .stats-row { justify-content: center; } }
|
|
`
|
|
|
|
const hourCountsJson = getHourCountsJson(data.message_hours)
|
|
|
|
const js = `
|
|
function toggleCollapsible(header) {
|
|
header.classList.toggle('open');
|
|
const content = header.nextElementSibling;
|
|
content.classList.toggle('open');
|
|
}
|
|
function copyText(btn) {
|
|
const code = btn.previousElementSibling;
|
|
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
|
|
});
|
|
}
|
|
function copyCmdItem(idx) {
|
|
const checkbox = document.getElementById('cmd-' + idx);
|
|
if (checkbox) {
|
|
const text = checkbox.dataset.text;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const btn = checkbox.nextElementSibling.querySelector('.copy-btn');
|
|
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); }
|
|
});
|
|
}
|
|
}
|
|
function copyAllCheckedClaudeMd() {
|
|
const checkboxes = document.querySelectorAll('.cmd-checkbox:checked');
|
|
const texts = [];
|
|
checkboxes.forEach(cb => {
|
|
if (cb.dataset.text) { texts.push(cb.dataset.text); }
|
|
});
|
|
const combined = texts.join('\\n');
|
|
const btn = document.querySelector('.copy-all-btn');
|
|
if (btn) {
|
|
navigator.clipboard.writeText(combined).then(() => {
|
|
btn.textContent = 'Copied ' + texts.length + ' items!';
|
|
btn.classList.add('copied');
|
|
setTimeout(() => { btn.textContent = 'Copy All Checked'; btn.classList.remove('copied'); }, 2000);
|
|
});
|
|
}
|
|
}
|
|
// Timezone selector for time of day chart (data is from our own analytics, not user input)
|
|
const rawHourCounts = ${hourCountsJson};
|
|
function updateHourHistogram(offsetFromPT) {
|
|
const periods = [
|
|
{ label: "Morning (6-12)", range: [6,7,8,9,10,11] },
|
|
{ label: "Afternoon (12-18)", range: [12,13,14,15,16,17] },
|
|
{ label: "Evening (18-24)", range: [18,19,20,21,22,23] },
|
|
{ label: "Night (0-6)", range: [0,1,2,3,4,5] }
|
|
];
|
|
const adjustedCounts = {};
|
|
for (const [hour, count] of Object.entries(rawHourCounts)) {
|
|
const newHour = (parseInt(hour) + offsetFromPT + 24) % 24;
|
|
adjustedCounts[newHour] = (adjustedCounts[newHour] || 0) + count;
|
|
}
|
|
const periodCounts = periods.map(p => ({
|
|
label: p.label,
|
|
count: p.range.reduce((sum, h) => sum + (adjustedCounts[h] || 0), 0)
|
|
}));
|
|
const maxCount = Math.max(...periodCounts.map(p => p.count)) || 1;
|
|
const container = document.getElementById('hour-histogram');
|
|
container.textContent = '';
|
|
periodCounts.forEach(p => {
|
|
const row = document.createElement('div');
|
|
row.className = 'bar-row';
|
|
const label = document.createElement('div');
|
|
label.className = 'bar-label';
|
|
label.textContent = p.label;
|
|
const track = document.createElement('div');
|
|
track.className = 'bar-track';
|
|
const fill = document.createElement('div');
|
|
fill.className = 'bar-fill';
|
|
fill.style.width = (p.count / maxCount) * 100 + '%';
|
|
fill.style.background = '#8b5cf6';
|
|
track.appendChild(fill);
|
|
const value = document.createElement('div');
|
|
value.className = 'bar-value';
|
|
value.textContent = p.count;
|
|
row.appendChild(label);
|
|
row.appendChild(track);
|
|
row.appendChild(value);
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
document.getElementById('timezone-select').addEventListener('change', function() {
|
|
const customInput = document.getElementById('custom-offset');
|
|
if (this.value === 'custom') {
|
|
customInput.style.display = 'inline-block';
|
|
customInput.focus();
|
|
} else {
|
|
customInput.style.display = 'none';
|
|
updateHourHistogram(parseInt(this.value));
|
|
}
|
|
});
|
|
document.getElementById('custom-offset').addEventListener('change', function() {
|
|
const offset = parseInt(this.value) + 8;
|
|
updateHourHistogram(offset);
|
|
});
|
|
`
|
|
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Claude Code Insights</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>${css}</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Claude Code Insights</h1>
|
|
<p class="subtitle">${data.total_messages.toLocaleString()} messages across ${data.total_sessions} sessions${data.total_sessions_scanned && data.total_sessions_scanned > data.total_sessions ? ` (${data.total_sessions_scanned.toLocaleString()} total)` : ''} | ${data.date_range.start} to ${data.date_range.end}</p>
|
|
|
|
${atAGlanceHtml}
|
|
|
|
<nav class="nav-toc">
|
|
<a href="#section-work">What You Work On</a>
|
|
<a href="#section-usage">How You Use CC</a>
|
|
<a href="#section-wins">Impressive Things</a>
|
|
<a href="#section-friction">Where Things Go Wrong</a>
|
|
<a href="#section-features">Features to Try</a>
|
|
<a href="#section-patterns">New Usage Patterns</a>
|
|
<a href="#section-horizon">On the Horizon</a>
|
|
<a href="#section-feedback">Team Feedback</a>
|
|
</nav>
|
|
|
|
<div class="stats-row">
|
|
<div class="stat"><div class="stat-value">${data.total_messages.toLocaleString()}</div><div class="stat-label">Messages</div></div>
|
|
<div class="stat"><div class="stat-value">+${data.total_lines_added.toLocaleString()}/-${data.total_lines_removed.toLocaleString()}</div><div class="stat-label">Lines</div></div>
|
|
<div class="stat"><div class="stat-value">${data.total_files_modified}</div><div class="stat-label">Files</div></div>
|
|
<div class="stat"><div class="stat-value">${data.days_active}</div><div class="stat-label">Days</div></div>
|
|
<div class="stat"><div class="stat-value">${data.messages_per_day}</div><div class="stat-label">Msgs/Day</div></div>
|
|
</div>
|
|
|
|
${projectAreasHtml}
|
|
|
|
<div class="charts-row">
|
|
<div class="chart-card">
|
|
<div class="chart-title">What You Wanted</div>
|
|
${generateBarChart(data.goal_categories, '#2563eb')}
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">Top Tools Used</div>
|
|
${generateBarChart(data.tool_counts, '#0891b2')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="charts-row">
|
|
<div class="chart-card">
|
|
<div class="chart-title">Languages</div>
|
|
${generateBarChart(data.languages, '#10b981')}
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">Session Types</div>
|
|
${generateBarChart(data.session_types || {}, '#8b5cf6')}
|
|
</div>
|
|
</div>
|
|
|
|
${interactionHtml}
|
|
|
|
<!-- Response Time Distribution -->
|
|
<div class="chart-card" style="margin: 24px 0;">
|
|
<div class="chart-title">User Response Time Distribution</div>
|
|
${generateResponseTimeHistogram(data.user_response_times)}
|
|
<div style="font-size: 12px; color: #64748b; margin-top: 8px;">
|
|
Median: ${data.median_response_time.toFixed(1)}s • Average: ${data.avg_response_time.toFixed(1)}s
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Multi-clauding Section (matching Python reference) -->
|
|
<div class="chart-card" style="margin: 24px 0;">
|
|
<div class="chart-title">Multi-Clauding (Parallel Sessions)</div>
|
|
${
|
|
data.multi_clauding.overlap_events === 0
|
|
? `
|
|
<p style="font-size: 14px; color: #64748b; padding: 8px 0;">
|
|
No parallel session usage detected. You typically work with one Claude Code session at a time.
|
|
</p>
|
|
`
|
|
: `
|
|
<div style="display: flex; gap: 24px; margin: 12px 0;">
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.overlap_events}</div>
|
|
<div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Overlap Events</div>
|
|
</div>
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.sessions_involved}</div>
|
|
<div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Sessions Involved</div>
|
|
</div>
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.total_messages > 0 ? Math.round((100 * data.multi_clauding.user_messages_during) / data.total_messages) : 0}%</div>
|
|
<div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Of Messages</div>
|
|
</div>
|
|
</div>
|
|
<p style="font-size: 13px; color: #475569; margin-top: 12px;">
|
|
You run multiple Claude Code sessions simultaneously. Multi-clauding is detected when sessions
|
|
overlap in time, suggesting parallel workflows.
|
|
</p>
|
|
`
|
|
}
|
|
</div>
|
|
|
|
<!-- Time of Day & Tool Errors -->
|
|
<div class="charts-row">
|
|
<div class="chart-card">
|
|
<div class="chart-title" style="display: flex; align-items: center; gap: 12px;">
|
|
User Messages by Time of Day
|
|
<select id="timezone-select" style="font-size: 12px; padding: 4px 8px; border-radius: 4px; border: 1px solid #e2e8f0;">
|
|
<option value="0">PT (UTC-8)</option>
|
|
<option value="3">ET (UTC-5)</option>
|
|
<option value="8">London (UTC)</option>
|
|
<option value="9">CET (UTC+1)</option>
|
|
<option value="17">Tokyo (UTC+9)</option>
|
|
<option value="custom">Custom offset...</option>
|
|
</select>
|
|
<input type="number" id="custom-offset" placeholder="UTC offset" style="display: none; width: 80px; font-size: 12px; padding: 4px; border-radius: 4px; border: 1px solid #e2e8f0;">
|
|
</div>
|
|
${generateTimeOfDayChart(data.message_hours)}
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">Tool Errors Encountered</div>
|
|
${Object.keys(data.tool_error_categories).length > 0 ? generateBarChart(data.tool_error_categories, '#dc2626') : '<p class="empty">No tool errors</p>'}
|
|
</div>
|
|
</div>
|
|
|
|
${whatWorksHtml}
|
|
|
|
<div class="charts-row">
|
|
<div class="chart-card">
|
|
<div class="chart-title">What Helped Most (Claude's Capabilities)</div>
|
|
${generateBarChart(data.success, '#16a34a')}
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">Outcomes</div>
|
|
${generateBarChart(data.outcomes, '#8b5cf6', 6, OUTCOME_ORDER)}
|
|
</div>
|
|
</div>
|
|
|
|
${frictionHtml}
|
|
|
|
<div class="charts-row">
|
|
<div class="chart-card">
|
|
<div class="chart-title">Primary Friction Types</div>
|
|
${generateBarChart(data.friction, '#dc2626')}
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">Inferred Satisfaction (model-estimated)</div>
|
|
${generateBarChart(data.satisfaction, '#eab308', 6, SATISFACTION_ORDER)}
|
|
</div>
|
|
</div>
|
|
|
|
${suggestionsHtml}
|
|
|
|
${horizonHtml}
|
|
|
|
${funEndingHtml}
|
|
|
|
${teamFeedbackHtml}
|
|
</div>
|
|
<script>${js}</script>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
// ============================================================================
|
|
// Export Types & Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Structured export format for claudescope consumption
|
|
*/
|
|
type InsightsExport = {
|
|
metadata: {
|
|
username: string
|
|
generated_at: string
|
|
claude_code_version: string
|
|
date_range: { start: string; end: string }
|
|
session_count: number
|
|
remote_hosts_collected?: string[]
|
|
}
|
|
aggregated_data: AggregatedData
|
|
insights: InsightResults
|
|
facets_summary?: {
|
|
total: number
|
|
goal_categories: Record<string, number>
|
|
outcomes: Record<string, number>
|
|
satisfaction: Record<string, number>
|
|
friction: Record<string, number>
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Lite Session Scanning
|
|
// ============================================================================
|
|
|
|
type LiteSessionInfo = {
|
|
sessionId: string
|
|
path: string
|
|
mtime: number
|
|
size: number
|
|
}
|
|
|
|
/**
|
|
* Scans all project directories using filesystem metadata only (no JSONL parsing).
|
|
* Returns a list of session file info sorted by mtime descending.
|
|
* Yields to the event loop between project directories to keep the UI responsive.
|
|
*/
|
|
async function scanAllSessions(): Promise<LiteSessionInfo[]> {
|
|
const projectsDir = getProjectsDir()
|
|
|
|
let dirents: Dirent<string>[]
|
|
try {
|
|
dirents = await readdir(projectsDir, { withFileTypes: true })
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const projectDirs = dirents
|
|
.filter(dirent => dirent.isDirectory())
|
|
.map(dirent => join(projectsDir, dirent.name))
|
|
|
|
const allSessions: LiteSessionInfo[] = []
|
|
|
|
for (let i = 0; i < projectDirs.length; i++) {
|
|
const sessionFiles = await getSessionFilesWithMtime(projectDirs[i]!)
|
|
for (const [sessionId, fileInfo] of sessionFiles) {
|
|
allSessions.push({
|
|
sessionId,
|
|
path: fileInfo.path,
|
|
mtime: fileInfo.mtime,
|
|
size: fileInfo.size,
|
|
})
|
|
}
|
|
// Yield to event loop every 10 project directories
|
|
if (i % 10 === 9) {
|
|
await new Promise<void>(resolve => setImmediate(resolve))
|
|
}
|
|
}
|
|
|
|
// Sort by mtime descending (most recent first)
|
|
allSessions.sort((a, b) => b.mtime - a.mtime)
|
|
return allSessions
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Function
|
|
// ============================================================================
|
|
|
|
export async function generateUsageReport(options?: {
|
|
collectRemote?: boolean
|
|
}): Promise<{
|
|
insights: InsightResults
|
|
htmlPath: string
|
|
data: AggregatedData
|
|
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }
|
|
facets: Map<string, SessionFacets>
|
|
}> {
|
|
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
|
|
|
|
// Optionally collect data from remote hosts first (ant-only)
|
|
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
|
|
const destDir = join(getClaudeConfigHomeDir(), 'projects')
|
|
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
|
|
remoteStats = { hosts, totalCopied }
|
|
}
|
|
|
|
// Phase 1: Lite scan — filesystem metadata only (no JSONL parsing)
|
|
const allScannedSessions = await scanAllSessions()
|
|
const totalSessionsScanned = allScannedSessions.length
|
|
|
|
// Phase 2: Load SessionMeta — use cache where available, parse only uncached
|
|
// Read cached metas in parallel batches to avoid blocking the event loop
|
|
const META_BATCH_SIZE = 50
|
|
const MAX_SESSIONS_TO_LOAD = 200
|
|
let allMetas: SessionMeta[] = []
|
|
const uncachedSessions: LiteSessionInfo[] = []
|
|
|
|
for (let i = 0; i < allScannedSessions.length; i += META_BATCH_SIZE) {
|
|
const batch = allScannedSessions.slice(i, i + META_BATCH_SIZE)
|
|
const results = await Promise.all(
|
|
batch.map(async sessionInfo => ({
|
|
sessionInfo,
|
|
cached: await loadCachedSessionMeta(sessionInfo.sessionId),
|
|
})),
|
|
)
|
|
for (const { sessionInfo, cached } of results) {
|
|
if (cached) {
|
|
allMetas.push(cached)
|
|
} else if (uncachedSessions.length < MAX_SESSIONS_TO_LOAD) {
|
|
uncachedSessions.push(sessionInfo)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load full message data only for uncached sessions and compute SessionMeta
|
|
const logsForFacets = new Map<string, LogOption>()
|
|
|
|
// Filter out /insights meta-sessions (facet extraction API calls get logged as sessions)
|
|
const isMetaSession = (log: LogOption): boolean => {
|
|
for (const msg of log.messages.slice(0, 5)) {
|
|
if (msg.type === 'user' && msg.message) {
|
|
const content = msg.message.content
|
|
if (typeof content === 'string') {
|
|
if (
|
|
content.includes('RESPOND WITH ONLY A VALID JSON OBJECT') ||
|
|
content.includes('record_facets')
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Load uncached sessions in batches to yield to event loop between batches
|
|
const LOAD_BATCH_SIZE = 10
|
|
for (let i = 0; i < uncachedSessions.length; i += LOAD_BATCH_SIZE) {
|
|
const batch = uncachedSessions.slice(i, i + LOAD_BATCH_SIZE)
|
|
const batchResults = await Promise.all(
|
|
batch.map(async sessionInfo => {
|
|
try {
|
|
return await loadAllLogsFromSessionFile(sessionInfo.path)
|
|
} catch {
|
|
return []
|
|
}
|
|
}),
|
|
)
|
|
// Collect metas synchronously, then save them in parallel (independent writes)
|
|
const metasToSave: SessionMeta[] = []
|
|
for (const logs of batchResults) {
|
|
for (const log of logs) {
|
|
if (isMetaSession(log) || !hasValidDates(log)) continue
|
|
const meta = logToSessionMeta(log)
|
|
allMetas.push(meta)
|
|
metasToSave.push(meta)
|
|
// Keep the log around for potential facet extraction
|
|
logsForFacets.set(meta.session_id, log)
|
|
}
|
|
}
|
|
await Promise.all(metasToSave.map(meta => saveSessionMeta(meta)))
|
|
}
|
|
|
|
// Deduplicate session branches (keep the one with most user messages per session_id)
|
|
// This prevents inflated totals when a session has multiple conversation branches
|
|
const bestBySession = new Map<string, SessionMeta>()
|
|
for (const meta of allMetas) {
|
|
const existing = bestBySession.get(meta.session_id)
|
|
if (
|
|
!existing ||
|
|
meta.user_message_count > existing.user_message_count ||
|
|
(meta.user_message_count === existing.user_message_count &&
|
|
meta.duration_minutes > existing.duration_minutes)
|
|
) {
|
|
bestBySession.set(meta.session_id, meta)
|
|
}
|
|
}
|
|
// Replace allMetas with deduplicated list and remove unused logs from logsForFacets
|
|
const keptSessionIds = new Set(bestBySession.keys())
|
|
allMetas = [...bestBySession.values()]
|
|
for (const sessionId of logsForFacets.keys()) {
|
|
if (!keptSessionIds.has(sessionId)) {
|
|
logsForFacets.delete(sessionId)
|
|
}
|
|
}
|
|
|
|
// Sort all metas by start_time descending (most recent first)
|
|
allMetas.sort((a, b) => b.start_time.localeCompare(a.start_time))
|
|
|
|
// Pre-filter obviously minimal sessions to save API calls
|
|
// (matching Python's substantive filtering concept)
|
|
const isSubstantiveSession = (meta: SessionMeta): boolean => {
|
|
// Skip sessions with very few user messages
|
|
if (meta.user_message_count < 2) return false
|
|
// Skip very short sessions (< 1 minute)
|
|
if (meta.duration_minutes < 1) return false
|
|
return true
|
|
}
|
|
|
|
const substantiveMetas = allMetas.filter(isSubstantiveSession)
|
|
|
|
// Phase 3: Facet extraction — only for sessions without cached facets
|
|
const facets = new Map<string, SessionFacets>()
|
|
const toExtract: Array<{ log: LogOption; sessionId: string }> = []
|
|
const MAX_FACET_EXTRACTIONS = 50
|
|
|
|
// Load cached facets for all substantive sessions in parallel
|
|
const cachedFacetResults = await Promise.all(
|
|
substantiveMetas.map(async meta => ({
|
|
sessionId: meta.session_id,
|
|
cached: await loadCachedFacets(meta.session_id),
|
|
})),
|
|
)
|
|
for (const { sessionId, cached } of cachedFacetResults) {
|
|
if (cached) {
|
|
facets.set(sessionId, cached)
|
|
} else {
|
|
const log = logsForFacets.get(sessionId)
|
|
if (log && toExtract.length < MAX_FACET_EXTRACTIONS) {
|
|
toExtract.push({ log, sessionId })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract facets for sessions that need them (50 concurrent)
|
|
const CONCURRENCY = 50
|
|
for (let i = 0; i < toExtract.length; i += CONCURRENCY) {
|
|
const batch = toExtract.slice(i, i + CONCURRENCY)
|
|
const results = await Promise.all(
|
|
batch.map(async ({ log, sessionId }) => {
|
|
const newFacets = await extractFacetsFromAPI(log, sessionId)
|
|
return { sessionId, newFacets }
|
|
}),
|
|
)
|
|
// Collect facets synchronously, save in parallel (independent writes)
|
|
const facetsToSave: SessionFacets[] = []
|
|
for (const { sessionId, newFacets } of results) {
|
|
if (newFacets) {
|
|
facets.set(sessionId, newFacets)
|
|
facetsToSave.push(newFacets)
|
|
}
|
|
}
|
|
await Promise.all(facetsToSave.map(f => saveFacets(f)))
|
|
}
|
|
|
|
// Filter out warmup/minimal sessions (matching Python's is_minimal)
|
|
// A session is minimal if warmup_minimal is the ONLY goal category
|
|
const isMinimalSession = (sessionId: string): boolean => {
|
|
const sessionFacets = facets.get(sessionId)
|
|
if (!sessionFacets) return false
|
|
const cats = sessionFacets.goal_categories
|
|
const catKeys = safeKeys(cats).filter(k => (cats[k] ?? 0) > 0)
|
|
return catKeys.length === 1 && catKeys[0] === 'warmup_minimal'
|
|
}
|
|
|
|
const substantiveSessions = substantiveMetas.filter(
|
|
s => !isMinimalSession(s.session_id),
|
|
)
|
|
|
|
const substantiveFacets = new Map<string, SessionFacets>()
|
|
for (const [sessionId, f] of facets) {
|
|
if (!isMinimalSession(sessionId)) {
|
|
substantiveFacets.set(sessionId, f)
|
|
}
|
|
}
|
|
|
|
const aggregated = aggregateData(substantiveSessions, substantiveFacets)
|
|
aggregated.total_sessions_scanned = totalSessionsScanned
|
|
|
|
// Generate parallel insights from Claude (6 sections)
|
|
const insights = await generateParallelInsights(aggregated, facets)
|
|
|
|
// Generate HTML report
|
|
const htmlReport = generateHtmlReport(aggregated, insights)
|
|
|
|
// Save reports
|
|
try {
|
|
await mkdir(getDataDir(), { recursive: true })
|
|
} catch {
|
|
// Directory may already exist
|
|
}
|
|
|
|
const htmlPath = join(getDataDir(), 'report.html')
|
|
await writeFile(htmlPath, htmlReport, {
|
|
encoding: 'utf-8',
|
|
mode: 0o600,
|
|
})
|
|
|
|
return {
|
|
insights,
|
|
htmlPath,
|
|
data: aggregated,
|
|
remoteStats,
|
|
facets: substantiveFacets,
|
|
}
|
|
}
|
|
|
|
function safeEntries<V>(
|
|
obj: Record<string, V> | undefined | null,
|
|
): [string, V][] {
|
|
return obj ? Object.entries(obj) : []
|
|
}
|
|
|
|
function safeKeys(obj: Record<string, unknown> | undefined | null): string[] {
|
|
return obj ? Object.keys(obj) : []
|
|
}
|
|
|
|
// ============================================================================
|
|
// Command Definition
|
|
// ============================================================================
|
|
|
|
const usageReport: Command = {
|
|
type: 'prompt',
|
|
name: 'insights',
|
|
description: 'Generate a report analyzing your Claude Code sessions',
|
|
contentLength: 0, // Dynamic content
|
|
progressMessage: 'analyzing your sessions',
|
|
source: 'builtin',
|
|
async getPromptForCommand(args) {
|
|
let collectRemote = false
|
|
let remoteHosts: string[] = []
|
|
let hasRemoteHosts = false
|
|
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
// Parse --homespaces flag
|
|
collectRemote = args?.includes('--homespaces') ?? false
|
|
|
|
// Check for available remote hosts
|
|
remoteHosts = await getRunningRemoteHosts()
|
|
hasRemoteHosts = remoteHosts.length > 0
|
|
|
|
// Show collection message if collecting
|
|
if (collectRemote && hasRemoteHosts) {
|
|
console.error(
|
|
`Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`,
|
|
)
|
|
}
|
|
}
|
|
|
|
const { insights, htmlPath, data, remoteStats } = await generateUsageReport(
|
|
{ collectRemote },
|
|
)
|
|
|
|
let reportUrl = `file://${htmlPath}`
|
|
let uploadHint = ''
|
|
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
// Try to upload to S3
|
|
const timestamp = new Date()
|
|
.toISOString()
|
|
.replace(/[-:]/g, '')
|
|
.replace('T', '_')
|
|
.slice(0, 15)
|
|
const username = process.env.SAFEUSER || process.env.USER || 'unknown'
|
|
const filename = `${username}_insights_${timestamp}.html`
|
|
const s3Path = `s3://anthropic-serve/atamkin/cc-user-reports/${filename}`
|
|
const s3Url = `https://s3-frontend.infra.ant.dev/anthropic-serve/atamkin/cc-user-reports/${filename}`
|
|
|
|
reportUrl = s3Url
|
|
try {
|
|
execFileSync('ff', ['cp', htmlPath, s3Path], {
|
|
timeout: 60000,
|
|
stdio: 'pipe', // Suppress output
|
|
})
|
|
} catch {
|
|
// Upload failed - fall back to local file and show upload command
|
|
reportUrl = `file://${htmlPath}`
|
|
uploadHint = `\nAutomatic upload failed. Are you on the boron namespace? Try \`use-bo\` and ensure you've run \`sso\`.
|
|
To share, run: ff cp ${htmlPath} ${s3Path}
|
|
Then access at: ${s3Url}`
|
|
}
|
|
}
|
|
|
|
// Build header with stats
|
|
const sessionLabel =
|
|
data.total_sessions_scanned &&
|
|
data.total_sessions_scanned > data.total_sessions
|
|
? `${data.total_sessions_scanned.toLocaleString()} sessions total · ${data.total_sessions} analyzed`
|
|
: `${data.total_sessions} sessions`
|
|
const stats = [
|
|
sessionLabel,
|
|
`${data.total_messages.toLocaleString()} messages`,
|
|
`${Math.round(data.total_duration_hours)}h`,
|
|
`${data.git_commits} commits`,
|
|
].join(' · ')
|
|
|
|
// Build remote host info (ant-only)
|
|
let remoteInfo = ''
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
if (remoteStats && remoteStats.totalCopied > 0) {
|
|
const hsNames = remoteStats.hosts
|
|
.filter(h => h.sessionCount > 0)
|
|
.map(h => h.name)
|
|
.join(', ')
|
|
remoteInfo = `\n_Collected ${remoteStats.totalCopied} new sessions from: ${hsNames}_\n`
|
|
} else if (!collectRemote && hasRemoteHosts) {
|
|
// Suggest using --homespaces if they have remote hosts but didn't use the flag
|
|
remoteInfo = `\n_Tip: Run \`/insights --homespaces\` to include sessions from your ${remoteHosts.length} running homespace(s)_\n`
|
|
}
|
|
}
|
|
|
|
// Build markdown summary from insights
|
|
const atAGlance = insights.at_a_glance
|
|
const summaryText = atAGlance
|
|
? `## At a Glance
|
|
|
|
${atAGlance.whats_working ? `**What's working:** ${atAGlance.whats_working} See _Impressive Things You Did_.` : ''}
|
|
|
|
${atAGlance.whats_hindering ? `**What's hindering you:** ${atAGlance.whats_hindering} See _Where Things Go Wrong_.` : ''}
|
|
|
|
${atAGlance.quick_wins ? `**Quick wins to try:** ${atAGlance.quick_wins} See _Features to Try_.` : ''}
|
|
|
|
${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitious_workflows} See _On the Horizon_.` : ''}`
|
|
: '_No insights generated_'
|
|
|
|
const header = `# Claude Code Insights
|
|
|
|
${stats}
|
|
${data.date_range.start} to ${data.date_range.end}
|
|
${remoteInfo}
|
|
`
|
|
|
|
const userSummary = `${header}${summaryText}
|
|
|
|
Your full shareable insights report is ready: ${reportUrl}${uploadHint}`
|
|
|
|
// Return prompt for Claude to respond to
|
|
return [
|
|
{
|
|
type: 'text',
|
|
text: `The user just ran /insights to generate a usage report analyzing their Claude Code sessions.
|
|
|
|
Here is the full insights data:
|
|
${jsonStringify(insights, null, 2)}
|
|
|
|
Report URL: ${reportUrl}
|
|
HTML file: ${htmlPath}
|
|
Facets directory: ${getFacetsDir()}
|
|
|
|
Here is what the user sees:
|
|
${userSummary}
|
|
|
|
Now output the following message exactly:
|
|
|
|
<message>
|
|
Your shareable insights report is ready:
|
|
${reportUrl}${uploadHint}
|
|
|
|
Want to dig into any section or try one of the suggestions?
|
|
</message>`,
|
|
},
|
|
]
|
|
},
|
|
}
|
|
|
|
function isValidSessionFacets(obj: unknown): obj is SessionFacets {
|
|
if (!obj || typeof obj !== 'object') return false
|
|
const o = obj as Record<string, unknown>
|
|
return (
|
|
typeof o.underlying_goal === 'string' &&
|
|
typeof o.outcome === 'string' &&
|
|
typeof o.brief_summary === 'string' &&
|
|
o.goal_categories !== null &&
|
|
typeof o.goal_categories === 'object' &&
|
|
o.user_satisfaction_counts !== null &&
|
|
typeof o.user_satisfaction_counts === 'object' &&
|
|
o.friction_counts !== null &&
|
|
typeof o.friction_counts === 'object'
|
|
)
|
|
}
|
|
|
|
export default usageReport
|