cc-vision-hook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/README.zh-CN.md +158 -0
  5. package/dist/cache.d.ts +70 -0
  6. package/dist/cache.js +164 -0
  7. package/dist/cache.js.map +1 -0
  8. package/dist/claudeSettings.d.ts +31 -0
  9. package/dist/claudeSettings.js +115 -0
  10. package/dist/claudeSettings.js.map +1 -0
  11. package/dist/cli.d.ts +7 -0
  12. package/dist/cli.js +106 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/commands/configCmd.d.ts +18 -0
  15. package/dist/commands/configCmd.js +104 -0
  16. package/dist/commands/configCmd.js.map +1 -0
  17. package/dist/commands/doctor.d.ts +10 -0
  18. package/dist/commands/doctor.js +60 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/install.d.ts +16 -0
  21. package/dist/commands/install.js +48 -0
  22. package/dist/commands/install.js.map +1 -0
  23. package/dist/commands/status.d.ts +9 -0
  24. package/dist/commands/status.js +35 -0
  25. package/dist/commands/status.js.map +1 -0
  26. package/dist/commands/testImage.d.ts +11 -0
  27. package/dist/commands/testImage.js +40 -0
  28. package/dist/commands/testImage.js.map +1 -0
  29. package/dist/commands/toggle.d.ts +10 -0
  30. package/dist/commands/toggle.js +21 -0
  31. package/dist/commands/toggle.js.map +1 -0
  32. package/dist/config.d.ts +72 -0
  33. package/dist/config.js +94 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/extract.d.ts +32 -0
  36. package/dist/extract.js +105 -0
  37. package/dist/extract.js.map +1 -0
  38. package/dist/hooks/postToolUse.d.ts +27 -0
  39. package/dist/hooks/postToolUse.js +53 -0
  40. package/dist/hooks/postToolUse.js.map +1 -0
  41. package/dist/hooks/userPromptSubmit.d.ts +29 -0
  42. package/dist/hooks/userPromptSubmit.js +64 -0
  43. package/dist/hooks/userPromptSubmit.js.map +1 -0
  44. package/dist/index.d.ts +4 -0
  45. package/dist/index.js +5 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/pasteScanner.d.ts +37 -0
  48. package/dist/pasteScanner.js +68 -0
  49. package/dist/pasteScanner.js.map +1 -0
  50. package/dist/sdk/index.d.ts +12 -0
  51. package/dist/sdk/index.js +12 -0
  52. package/dist/sdk/index.js.map +1 -0
  53. package/dist/seenTracker.d.ts +23 -0
  54. package/dist/seenTracker.js +46 -0
  55. package/dist/seenTracker.js.map +1 -0
  56. package/dist/vision.d.ts +40 -0
  57. package/dist/vision.js +87 -0
  58. package/dist/vision.js.map +1 -0
  59. package/package.json +66 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `cvh enable` / `cvh disable` 命令实现:只写配置文件的 enabled 字段,不涉及 Hook 注册。
3
+ */
4
+ /**
5
+ * 设置 enabled 字段并落盘。
6
+ *
7
+ * @param enabled - 目标开关状态
8
+ * @param jsonOutput - 是否以 JSON 格式输出结果
9
+ */
10
+ export declare const runToggle: (enabled: boolean, jsonOutput: boolean) => Promise<void>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `cvh enable` / `cvh disable` 命令实现:只写配置文件的 enabled 字段,不涉及 Hook 注册。
3
+ */
4
+ import { loadConfig, saveConfig } from "../config.js";
5
+ /**
6
+ * 设置 enabled 字段并落盘。
7
+ *
8
+ * @param enabled - 目标开关状态
9
+ * @param jsonOutput - 是否以 JSON 格式输出结果
10
+ */
11
+ export const runToggle = async (enabled, jsonOutput) => {
12
+ const config = await loadConfig();
13
+ config.enabled = enabled;
14
+ await saveConfig(config);
15
+ if (jsonOutput) {
16
+ console.log(JSON.stringify({ ok: true, enabled }));
17
+ return;
18
+ }
19
+ console.log(enabled ? "✅ cvh 已启用" : "⚠️ cvh 已停用");
20
+ };
21
+ //# sourceMappingURL=toggle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"toggle.js","sourceRoot":"","sources":["../../src/commands/toggle.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,KAAK,EAAE,OAAgB,EAAE,UAAmB,EAAiB,EAAE;IACtF,MAAM,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;IAClC,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,MAAM,UAAU,CAAC,MAAM,CAAC,CAAC;IAEzB,IAAI,UAAU,EAAE,CAAC;QACf,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;QACnD,OAAO;IACT,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC;AACrD,CAAC,CAAC"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `cvh` 配置读写:拍平一层 JSON,存于 `~/.claude/cc-vision-hook.json`。
3
+ * 优先级:CLI flags > 环境变量 > config json > 默认值(CLI/env 合并逻辑在调用处完成,本文件只负责 json 层)。
4
+ */
5
+ /** 支持的视觉解析上游 provider(内部映射到对应的 AI SDK 包,见 vision.ts)。 */
6
+ export type CvhProvider = "oai" | "responses" | "anthropic" | "gemini";
7
+ export interface CvhConfig {
8
+ /** 唯一运行开关:true 处理图片,false 立即空返回。默认 false(安装后需要显式 enable)。 */
9
+ enabled: boolean;
10
+ /** 视觉模型上游 provider。 */
11
+ provider: CvhProvider;
12
+ /** 视觉模型名(provider 内部的模型 id)。 */
13
+ model: string;
14
+ /** 上游 API base URL,留空则使用 provider 默认官方地址。 */
15
+ baseUrl?: string;
16
+ /**
17
+ * API Key,明文存储(用户决策:明文可接受,类比 Claude Code 第三方接入本身也是明文存 code/token)。
18
+ * install 时会把配置文件权限设为 0600,降低同机其他用户读取的风险。
19
+ */
20
+ apiKey?: string;
21
+ /** 单次视觉解析请求超时(毫秒)。 */
22
+ timeoutMs: number;
23
+ /** 视觉模型单次回复最大 token 数。 */
24
+ maxTokens: number;
25
+ /** 磁盘缓存 TTL(天)。全局共享,见 cache.ts。 */
26
+ cache: {
27
+ ttlDays: number;
28
+ };
29
+ /** 是否已安装 MCP server(仅用于 status/doctor 展示,不参与开关逻辑)。 */
30
+ mcpInstalled: boolean;
31
+ }
32
+ export declare const DEFAULT_CONFIG: CvhConfig;
33
+ /**
34
+ * Claude Code 配置根目录(即 `~/.claude`)。
35
+ *
36
+ * 支持 `CVH_CLAUDE_HOME` 环境变量覆盖,用于:
37
+ * ① 单测/集成测试注入隔离的临时目录,避免污染真实 `~/.claude`;
38
+ * ② 真机验证时用隔离的临时 HOME 跑一次真实 Claude Code 会话,不影响用户日常配置。
39
+ * 所有需要拼 `~/.claude/...` 路径的模块都应该调用这个函数,不要各自直接 `homedir()`。
40
+ *
41
+ * @returns Claude Code 配置根目录的绝对路径
42
+ */
43
+ export declare const getClaudeHomeDir: () => string;
44
+ /** `cvh` 在用户主目录下的根目录:`~/.claude/cc-vision-hook/`。 */
45
+ export declare const getCvhHomeDir: () => string;
46
+ /** 配置文件路径:`~/.claude/cc-vision-hook.json`(与 Claude Code 自身配置同级,便于用户发现)。 */
47
+ export declare const getConfigPath: () => string;
48
+ /**
49
+ * 读取配置文件,文件不存在时返回默认配置(不落盘,install 阶段才落盘)。
50
+ *
51
+ * @returns 合并后的配置对象
52
+ */
53
+ export declare const loadConfig: () => Promise<CvhConfig>;
54
+ /**
55
+ * 写入配置文件,并设置 `0600` 权限(仅当前用户可读写,因为里面可能存明文 API key)。
56
+ *
57
+ * @param config - 要写入的完整配置对象
58
+ */
59
+ export declare const saveConfig: (config: CvhConfig) => Promise<void>;
60
+ /**
61
+ * 读取环境变量覆盖项(`CVH_*`),用于命令行免配置文件临时覆盖场景。
62
+ *
63
+ * @returns 环境变量中显式设置的配置片段(未设置的字段不出现在返回对象中)
64
+ */
65
+ export declare const loadEnvOverrides: () => Partial<CvhConfig>;
66
+ /**
67
+ * 按优先级合并配置:环境变量 > 配置文件 > 默认值。
68
+ * (CLI flags 由调用方在拿到这个结果之后再叠加一层,优先级最高。)
69
+ *
70
+ * @returns 最终生效的配置对象
71
+ */
72
+ export declare const resolveConfig: () => Promise<CvhConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `cvh` 配置读写:拍平一层 JSON,存于 `~/.claude/cc-vision-hook.json`。
3
+ * 优先级:CLI flags > 环境变量 > config json > 默认值(CLI/env 合并逻辑在调用处完成,本文件只负责 json 层)。
4
+ */
5
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join, dirname } from "node:path";
8
+ export const DEFAULT_CONFIG = {
9
+ enabled: false,
10
+ provider: "oai",
11
+ model: "gpt-4o-mini",
12
+ timeoutMs: 45000,
13
+ maxTokens: 1200,
14
+ cache: { ttlDays: 7 },
15
+ mcpInstalled: false,
16
+ };
17
+ /**
18
+ * Claude Code 配置根目录(即 `~/.claude`)。
19
+ *
20
+ * 支持 `CVH_CLAUDE_HOME` 环境变量覆盖,用于:
21
+ * ① 单测/集成测试注入隔离的临时目录,避免污染真实 `~/.claude`;
22
+ * ② 真机验证时用隔离的临时 HOME 跑一次真实 Claude Code 会话,不影响用户日常配置。
23
+ * 所有需要拼 `~/.claude/...` 路径的模块都应该调用这个函数,不要各自直接 `homedir()`。
24
+ *
25
+ * @returns Claude Code 配置根目录的绝对路径
26
+ */
27
+ export const getClaudeHomeDir = () => process.env.CVH_CLAUDE_HOME || join(homedir(), ".claude");
28
+ /** `cvh` 在用户主目录下的根目录:`~/.claude/cc-vision-hook/`。 */
29
+ export const getCvhHomeDir = () => join(getClaudeHomeDir(), "cc-vision-hook");
30
+ /** 配置文件路径:`~/.claude/cc-vision-hook.json`(与 Claude Code 自身配置同级,便于用户发现)。 */
31
+ export const getConfigPath = () => join(getClaudeHomeDir(), "cc-vision-hook.json");
32
+ /**
33
+ * 读取配置文件,文件不存在时返回默认配置(不落盘,install 阶段才落盘)。
34
+ *
35
+ * @returns 合并后的配置对象
36
+ */
37
+ export const loadConfig = async () => {
38
+ try {
39
+ const raw = await readFile(getConfigPath(), "utf8");
40
+ const parsed = JSON.parse(raw);
41
+ return { ...DEFAULT_CONFIG, ...parsed, cache: { ...DEFAULT_CONFIG.cache, ...parsed.cache } };
42
+ }
43
+ catch {
44
+ return { ...DEFAULT_CONFIG };
45
+ }
46
+ };
47
+ /**
48
+ * 写入配置文件,并设置 `0600` 权限(仅当前用户可读写,因为里面可能存明文 API key)。
49
+ *
50
+ * @param config - 要写入的完整配置对象
51
+ */
52
+ export const saveConfig = async (config) => {
53
+ const path = getConfigPath();
54
+ await mkdir(dirname(path), { recursive: true });
55
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
56
+ // 明文 API key 落盘,权限收紧到仅当前用户可读写,降低同机多用户环境下的泄露面。
57
+ await chmod(path, 0o600).catch(() => { });
58
+ };
59
+ /**
60
+ * 读取环境变量覆盖项(`CVH_*`),用于命令行免配置文件临时覆盖场景。
61
+ *
62
+ * @returns 环境变量中显式设置的配置片段(未设置的字段不出现在返回对象中)
63
+ */
64
+ export const loadEnvOverrides = () => {
65
+ const overrides = {};
66
+ if (process.env.CVH_ENABLED !== undefined)
67
+ overrides.enabled = process.env.CVH_ENABLED === "1" || process.env.CVH_ENABLED === "true";
68
+ if (process.env.CVH_PROVIDER)
69
+ overrides.provider = process.env.CVH_PROVIDER;
70
+ if (process.env.CVH_MODEL)
71
+ overrides.model = process.env.CVH_MODEL;
72
+ if (process.env.CVH_BASE_URL)
73
+ overrides.baseUrl = process.env.CVH_BASE_URL;
74
+ if (process.env.CVH_API_KEY)
75
+ overrides.apiKey = process.env.CVH_API_KEY;
76
+ if (process.env.CVH_TIMEOUT_MS)
77
+ overrides.timeoutMs = Number(process.env.CVH_TIMEOUT_MS);
78
+ if (process.env.CVH_MAX_TOKENS)
79
+ overrides.maxTokens = Number(process.env.CVH_MAX_TOKENS);
80
+ return overrides;
81
+ };
82
+ /**
83
+ * 按优先级合并配置:环境变量 > 配置文件 > 默认值。
84
+ * (CLI flags 由调用方在拿到这个结果之后再叠加一层,优先级最高。)
85
+ *
86
+ * @returns 最终生效的配置对象
87
+ */
88
+ export const resolveConfig = async () => {
89
+ const fileConfig = await loadConfig();
90
+ const envOverrides = loadEnvOverrides();
91
+ // envOverrides 目前不产生 cache 字段(无对应 CVH_CACHE_* 环境变量),这里始终沿用 fileConfig.cache。
92
+ return { ...fileConfig, ...envOverrides, cache: fileConfig.cache };
93
+ };
94
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 通用图片提取器:不依赖任何工具的固定 schema,只依赖结构自省 + 少量已知的旁路触发规则。
3
+ *
4
+ * 已知三种真实 tool_response shape,彼此完全不同:
5
+ * - Read 工具:判别式对象 { type: "image", file: { base64, type, ... } }
6
+ * - MCP 工具:content block 数组 [{ type: "image", source: { type, media_type, data } }]
7
+ * - Bash/PowerShell 工具:扁平对象 + isImage 布尔标志,图片是 stdout 里的完整 data URI 字符串
8
+ * 因为 cvh 的策略是"只追加不替换"(不需要精确还原成目标 schema),
9
+ * 提取器只需要"找到"图片,不需要保留可回写的精确路径信息。
10
+ */
11
+ /** 单张被提取出来的图片。 */
12
+ export interface ExtractedImage {
13
+ /** 不含 data URI 前缀的纯 base64 数据。 */
14
+ base64: string;
15
+ /** 形如 "image/png" 的 MIME 类型。 */
16
+ mimeType: string;
17
+ /** 在原始结构中的调试定位路径(如 "$.file.base64"),仅用于日志排查,不用于回写。 */
18
+ path: string;
19
+ }
20
+ /**
21
+ * 从任意嵌套的 JSON 结构(对象/数组/字符串)中递归提取出所有可识别的图片。
22
+ *
23
+ * 识别规则(对应三种已知真实样本):
24
+ * - 规则 A:某一层同时具备"base64 类字段"+"mime 类型字段"(Read/MCP 场景);
25
+ * - 规则 B:某一层存在 `isImage === true` 标志时,去检查同级的 stdout/output/content 字段(Bash/PowerShell 场景);
26
+ * - 规则 C:字符串本身就是一个完整的 data URI(配合规则 B 触发,也可能独立命中)。
27
+ *
28
+ * @param node - 待扫描的任意 JSON 值(一般传入某个工具的 tool_response)
29
+ * @param path - 当前递归位置的调试定位路径,外部调用无需传入
30
+ * @returns 本次扫描发现的所有图片(可能为空数组)
31
+ */
32
+ export declare const extractImages: (node: unknown, path?: string) => ExtractedImage[];
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 通用图片提取器:不依赖任何工具的固定 schema,只依赖结构自省 + 少量已知的旁路触发规则。
3
+ *
4
+ * 已知三种真实 tool_response shape,彼此完全不同:
5
+ * - Read 工具:判别式对象 { type: "image", file: { base64, type, ... } }
6
+ * - MCP 工具:content block 数组 [{ type: "image", source: { type, media_type, data } }]
7
+ * - Bash/PowerShell 工具:扁平对象 + isImage 布尔标志,图片是 stdout 里的完整 data URI 字符串
8
+ * 因为 cvh 的策略是"只追加不替换"(不需要精确还原成目标 schema),
9
+ * 提取器只需要"找到"图片,不需要保留可回写的精确路径信息。
10
+ */
11
+ // base64 解码后的大小上限(20MB)。参考 Claude Code 官方 BashTool 的 MAX_IMAGE_FILE_SIZE 同款阈值,
12
+ // 避免把无关的大 base64 字段误判成图片、发起不必要的视觉解析调用。
13
+ const MAX_DECODED_BYTES = 20 * 1024 * 1024;
14
+ const IMAGE_TYPE_MARKERS = new Set(["image", "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", "image/bmp"]);
15
+ const BASE64_FIELD_NAMES = ["base64", "data"];
16
+ const MIME_FIELD_NAMES = ["type", "media_type", "mimeType"];
17
+ // Bash/PowerShell 场景下,isImage===true 时应该去检查的候选字符串字段名。
18
+ const DATA_URI_CARRIER_FIELDS = ["stdout", "output", "content"];
19
+ const DATA_URI_RE = /^data:(image\/[a-z0-9.+_-]+);base64,(.+)$/i;
20
+ // base64 字符集校验(允许换行/空白,做基础粗过滤,不追求 100% 严格)。
21
+ const BASE64_CHARSET_RE = /^[A-Za-z0-9+/=\s]+$/;
22
+ /**
23
+ * 粗略判断一个字符串是否"像"base64 编码内容:长度、字符集、且解码后不超过大小上限。
24
+ *
25
+ * @param value - 待检测的字符串
26
+ * @returns 是否可能是合法的图片 base64 数据
27
+ */
28
+ const looksLikeBase64 = (value) => {
29
+ if (value.length < 64)
30
+ return false; // 太短的字符串不太可能是图片
31
+ if (!BASE64_CHARSET_RE.test(value))
32
+ return false;
33
+ // base64 每 4 字符编码 3 字节,用长度估算解码后大小,避免真的 decode 一次超大字符串。
34
+ const estimatedBytes = (value.length / 4) * 3;
35
+ return estimatedBytes <= MAX_DECODED_BYTES;
36
+ };
37
+ /**
38
+ * 判断某个字符串值是否命中"图片类型标记"(如 "image"、"image/png" 等)。
39
+ *
40
+ * @param value - 待检测的字符串
41
+ * @returns 是否是已知的图片类型标记
42
+ */
43
+ const matchesImageTypeMarker = (value) => IMAGE_TYPE_MARKERS.has(value) || /^image\/[a-z0-9.+_-]+$/i.test(value);
44
+ /**
45
+ * 把 MIME 字段值归一化成标准的 "image/xxx" 形式(有些字段只给了 "image" 这种粗粒度值时兜底成 png)。
46
+ *
47
+ * @param raw - 原始 MIME 字段值
48
+ * @returns 归一化后的 MIME 类型字符串
49
+ */
50
+ const normalizeMime = (raw) => (raw === "image" ? "image/png" : raw);
51
+ /**
52
+ * 从任意嵌套的 JSON 结构(对象/数组/字符串)中递归提取出所有可识别的图片。
53
+ *
54
+ * 识别规则(对应三种已知真实样本):
55
+ * - 规则 A:某一层同时具备"base64 类字段"+"mime 类型字段"(Read/MCP 场景);
56
+ * - 规则 B:某一层存在 `isImage === true` 标志时,去检查同级的 stdout/output/content 字段(Bash/PowerShell 场景);
57
+ * - 规则 C:字符串本身就是一个完整的 data URI(配合规则 B 触发,也可能独立命中)。
58
+ *
59
+ * @param node - 待扫描的任意 JSON 值(一般传入某个工具的 tool_response)
60
+ * @param path - 当前递归位置的调试定位路径,外部调用无需传入
61
+ * @returns 本次扫描发现的所有图片(可能为空数组)
62
+ */
63
+ export const extractImages = (node, path = "$") => {
64
+ const results = [];
65
+ if (Array.isArray(node)) {
66
+ node.forEach((item, i) => results.push(...extractImages(item, `${path}[${i}]`)));
67
+ return results;
68
+ }
69
+ if (typeof node === "string") {
70
+ // 规则 C:字符串本身是 data URI。
71
+ const match = node.match(DATA_URI_RE);
72
+ if (match?.[1] && match[2] && looksLikeBase64(match[2])) {
73
+ results.push({ base64: match[2], mimeType: match[1], path });
74
+ }
75
+ return results;
76
+ }
77
+ if (node && typeof node === "object") {
78
+ const obj = node;
79
+ // 规则 A:本层同时具备 base64 字段 + mime 类型字段。
80
+ const base64Field = BASE64_FIELD_NAMES.find((f) => typeof obj[f] === "string" && looksLikeBase64(obj[f]));
81
+ if (base64Field) {
82
+ const mimeValue = MIME_FIELD_NAMES.map((f) => obj[f]).find((v) => typeof v === "string" && matchesImageTypeMarker(v));
83
+ if (typeof mimeValue === "string") {
84
+ results.push({ base64: obj[base64Field], mimeType: normalizeMime(mimeValue), path: `${path}.${base64Field}` });
85
+ }
86
+ }
87
+ // 规则 B:isImage===true 旁路标志,检查候选字符串字段是否为 data URI。
88
+ if (obj.isImage === true) {
89
+ for (const field of DATA_URI_CARRIER_FIELDS) {
90
+ const value = obj[field];
91
+ if (typeof value === "string")
92
+ results.push(...extractImages(value, `${path}.${field}`));
93
+ }
94
+ }
95
+ // 递归所有子字段,覆盖嵌套一层的情况(如 file.base64、source.data)。
96
+ for (const [key, value] of Object.entries(obj)) {
97
+ // 避免对已经在规则 B 里处理过的 data URI 载体字段重复递归产生重复结果。
98
+ if (obj.isImage === true && DATA_URI_CARRIER_FIELDS.includes(key))
99
+ continue;
100
+ results.push(...extractImages(value, `${path}.${key}`));
101
+ }
102
+ }
103
+ return results;
104
+ };
105
+ //# sourceMappingURL=extract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extract.js","sourceRoot":"","sources":["../src/extract.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `PostToolUse` Hook 处理逻辑。
3
+ *
4
+ * matcher 建议设为 `*`(匹配所有工具),检测逻辑在 hook 内部完成,不依赖工具名白名单——
5
+ * 这样能自动覆盖未来任何新工具。性能上已实测验证:Bun 进程启动 + import
6
+ * AI SDK provider 包耗时在 10ms 级别,可以接受挂在所有工具调用上。
7
+ *
8
+ * 只追加不替换:本 Hook 永远只输出 additionalContext,不使用 updatedToolOutput——
9
+ * 因为目标模型是"静默忽略型",不需要真的把 image block 从 tool_response 里挪走,
10
+ * 只需要给模型补一段文字描述(这也是本项目相比"替换"方案最大的简化点)。
11
+ */
12
+ import type { HookOutput } from "./userPromptSubmit.js";
13
+ /** Claude Code 传给 `PostToolUse` hook 的 stdin payload(仅取用到的字段)。 */
14
+ export interface PostToolUseInput {
15
+ session_id: string;
16
+ hook_event_name: "PostToolUse";
17
+ tool_name: string;
18
+ tool_input: unknown;
19
+ tool_response: unknown;
20
+ }
21
+ /**
22
+ * 处理一次 `PostToolUse` 事件。
23
+ *
24
+ * @param input - Claude Code 传入的 hook payload
25
+ * @returns 供 Claude Code 消费的 hook 输出(enabled=false 或未检测到图片时返回空对象)
26
+ */
27
+ export declare const handlePostToolUse: (input: PostToolUseInput) => Promise<HookOutput>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `PostToolUse` Hook 处理逻辑。
3
+ *
4
+ * matcher 建议设为 `*`(匹配所有工具),检测逻辑在 hook 内部完成,不依赖工具名白名单——
5
+ * 这样能自动覆盖未来任何新工具。性能上已实测验证:Bun 进程启动 + import
6
+ * AI SDK provider 包耗时在 10ms 级别,可以接受挂在所有工具调用上。
7
+ *
8
+ * 只追加不替换:本 Hook 永远只输出 additionalContext,不使用 updatedToolOutput——
9
+ * 因为目标模型是"静默忽略型",不需要真的把 image block 从 tool_response 里挪走,
10
+ * 只需要给模型补一段文字描述(这也是本项目相比"替换"方案最大的简化点)。
11
+ */
12
+ import { resolveConfig } from "../config.js";
13
+ import { extractImages } from "../extract.js";
14
+ import { findCacheByBytes, putCacheEntry } from "../cache.js";
15
+ import { describeImage } from "../vision.js";
16
+ /**
17
+ * 处理一次 `PostToolUse` 事件。
18
+ *
19
+ * @param input - Claude Code 传入的 hook payload
20
+ * @returns 供 Claude Code 消费的 hook 输出(enabled=false 或未检测到图片时返回空对象)
21
+ */
22
+ export const handlePostToolUse = async (input) => {
23
+ const config = await resolveConfig();
24
+ if (!config.enabled)
25
+ return {};
26
+ const images = extractImages(input.tool_response);
27
+ if (images.length === 0)
28
+ return {};
29
+ const blocks = [];
30
+ for (const image of images) {
31
+ try {
32
+ const bytes = Buffer.from(image.base64, "base64");
33
+ let entry = await findCacheByBytes(bytes);
34
+ if (!entry) {
35
+ const description = await describeImage(image.base64, image.mimeType, config);
36
+ entry = await putCacheEntry(bytes, image.mimeType, input.tool_name, description, config.cache.ttlDays);
37
+ }
38
+ blocks.push(`<tool_image_vision id="${entry.imageId}" tool="${input.tool_name}">\n${entry.description}\n</tool_image_vision>`);
39
+ }
40
+ catch (error) {
41
+ blocks.push(`<tool_image_vision error="true" tool="${input.tool_name}">图片解析失败:${error instanceof Error ? error.message : String(error)}</tool_image_vision>`);
42
+ }
43
+ }
44
+ if (blocks.length === 0)
45
+ return {};
46
+ return {
47
+ hookSpecificOutput: {
48
+ hookEventName: "PostToolUse",
49
+ additionalContext: blocks.join("\n"),
50
+ },
51
+ };
52
+ };
53
+ //# sourceMappingURL=postToolUse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postToolUse.js","sourceRoot":"","sources":["../../src/hooks/postToolUse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAY7C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,EAAE,KAAuB,EAAuB,EAAE;IACtF,MAAM,MAAM,GAAG,MAAM,aAAa,EAAE,CAAC;IACrC,IAAI,CAAC,MAAM,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IAE/B,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;IAClD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEnC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAClD,IAAI,KAAK,GAAG,MAAM,gBAAgB,CAAC,KAAK,CAAC,CAAC;YAC1C,IAAI,CAAC,KAAK,EAAE,CAAC;gBACX,MAAM,WAAW,GAAG,MAAM,aAAa,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;gBAC9E,KAAK,GAAG,MAAM,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,EAAE,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YACzG,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,0BAA0B,KAAK,CAAC,OAAO,WAAW,KAAK,CAAC,SAAS,OAAO,KAAK,CAAC,WAAW,wBAAwB,CAAC,CAAC;QACjI,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,CAAC,IAAI,CACT,yCAAyC,KAAK,CAAC,SAAS,YAAY,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,sBAAsB,CACjJ,CAAC;QACJ,CAAC;IACH,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACnC,OAAO;QACL,kBAAkB,EAAE;YAClB,aAAa,EAAE,aAAa;YAC5B,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC;SACrC;KACF,CAAC;AACJ,CAAC,CAAC"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `UserPromptSubmit` Hook 处理逻辑。
3
+ *
4
+ * 流程:enabled? -> 扫描 ~/.claude/image-cache/<session_id>/ -> 过滤未处理过的图片
5
+ * -> 逐个"查缓存命中?/视觉解析->写缓存" -> 拼接 additionalContext。
6
+ *
7
+ * 只追加不替换:本 Hook 永远只输出 additionalContext,不尝试修改/删除原始 prompt
8
+ * (Claude Code 官方文档明确 UserPromptSubmit 无法替换 prompt,只能追加)。
9
+ */
10
+ /** Claude Code 传给 `UserPromptSubmit` hook 的 stdin payload(仅取用到的字段)。 */
11
+ export interface UserPromptSubmitInput {
12
+ session_id: string;
13
+ hook_event_name: "UserPromptSubmit";
14
+ prompt: string;
15
+ }
16
+ /** Hook 的标准输出结构。 */
17
+ export interface HookOutput {
18
+ hookSpecificOutput?: {
19
+ hookEventName: string;
20
+ additionalContext?: string;
21
+ };
22
+ }
23
+ /**
24
+ * 处理一次 `UserPromptSubmit` 事件。
25
+ *
26
+ * @param input - Claude Code 传入的 hook payload
27
+ * @returns 供 Claude Code 消费的 hook 输出(enabled=false 或没有新图片时返回空对象)
28
+ */
29
+ export declare const handleUserPromptSubmit: (input: UserPromptSubmitInput) => Promise<HookOutput>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `UserPromptSubmit` Hook 处理逻辑。
3
+ *
4
+ * 流程:enabled? -> 扫描 ~/.claude/image-cache/<session_id>/ -> 过滤未处理过的图片
5
+ * -> 逐个"查缓存命中?/视觉解析->写缓存" -> 拼接 additionalContext。
6
+ *
7
+ * 只追加不替换:本 Hook 永远只输出 additionalContext,不尝试修改/删除原始 prompt
8
+ * (Claude Code 官方文档明确 UserPromptSubmit 无法替换 prompt,只能追加)。
9
+ */
10
+ import { resolveConfig } from "../config.js";
11
+ import { scanPastedImages, readPastedImageBytes } from "../pasteScanner.js";
12
+ import { findCacheByBytes, putCacheEntry, computeImageId } from "../cache.js";
13
+ import { describeImage } from "../vision.js";
14
+ import { loadSeenImageIds, markImagesSeen } from "../seenTracker.js";
15
+ /**
16
+ * 处理一次 `UserPromptSubmit` 事件。
17
+ *
18
+ * @param input - Claude Code 传入的 hook payload
19
+ * @returns 供 Claude Code 消费的 hook 输出(enabled=false 或没有新图片时返回空对象)
20
+ */
21
+ export const handleUserPromptSubmit = async (input) => {
22
+ const config = await resolveConfig();
23
+ if (!config.enabled)
24
+ return {};
25
+ const pastedImages = await scanPastedImages(input.session_id);
26
+ if (pastedImages.length === 0)
27
+ return {};
28
+ const seenIds = await loadSeenImageIds(input.session_id);
29
+ const blocks = [];
30
+ const newlySeenIds = [];
31
+ for (const file of pastedImages) {
32
+ try {
33
+ const bytes = await readPastedImageBytes(file.path);
34
+ const imageId = computeImageId(bytes);
35
+ // 本 session 内已经展示过的图片不再重复塞进 additionalContext(内容 hash 缓存本身仍全局共享,
36
+ // 这里只是过滤"要不要在这一轮上下文里再提一次",避免多轮对话后上下文线性膨胀)。
37
+ if (seenIds.has(imageId))
38
+ continue;
39
+ let entry = await findCacheByBytes(bytes);
40
+ if (!entry) {
41
+ const base64 = bytes.toString("base64");
42
+ const description = await describeImage(base64, file.mimeType, config);
43
+ entry = await putCacheEntry(bytes, file.mimeType, "paste", description, config.cache.ttlDays);
44
+ }
45
+ blocks.push(`<image_vision id="${entry.imageId}">\n${entry.description}\n</image_vision>`);
46
+ newlySeenIds.push(entry.imageId);
47
+ }
48
+ catch (error) {
49
+ // 单张图片解析失败不应该阻断整个提交流程,跳过并继续处理其他图片。
50
+ blocks.push(`<image_vision error="true">图片解析失败:${error instanceof Error ? error.message : String(error)}</image_vision>`);
51
+ }
52
+ }
53
+ if (newlySeenIds.length > 0)
54
+ await markImagesSeen(input.session_id, newlySeenIds);
55
+ if (blocks.length === 0)
56
+ return {};
57
+ return {
58
+ hookSpecificOutput: {
59
+ hookEventName: "UserPromptSubmit",
60
+ additionalContext: blocks.join("\n"),
61
+ },
62
+ };
63
+ };
64
+ //# sourceMappingURL=userPromptSubmit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"userPromptSubmit.js","sourceRoot":"","sources":["../../src/hooks/userPromptSubmit.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAiBrE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,KAAK,EAAE,KAA4B,EAAuB,EAAE;IAChG,MAAM,MAAM,GAAG,MAAM,aAAa,EAAE,CAAC;IACrC,IAAI,CAAC,MAAM,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IAE/B,MAAM,YAAY,GAAG,MAAM,gBAAgB,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IAC9D,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEzC,MAAM,OAAO,GAAG,MAAM,gBAAgB,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACzD,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,YAAY,GAAa,EAAE,CAAC;IAElC,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;QAChC,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACpD,MAAM,OAAO,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;YACtC,iEAAiE;YACjE,2CAA2C;YAC3C,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;gBAAE,SAAS;YAEnC,IAAI,KAAK,GAAG,MAAM,gBAAgB,CAAC,KAAK,CAAC,CAAC;YAC1C,IAAI,CAAC,KAAK,EAAE,CAAC;gBACX,MAAM,MAAM,GAAG,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;gBACxC,MAAM,WAAW,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;gBACvE,KAAK,GAAG,MAAM,aAAa,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAChG,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,qBAAqB,KAAK,CAAC,OAAO,OAAO,KAAK,CAAC,WAAW,mBAAmB,CAAC,CAAC;YAC3F,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACnC,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,mCAAmC;YACnC,MAAM,CAAC,IAAI,CAAC,qCAAqC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;QAC5H,CAAC;IACH,CAAC;IAED,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,cAAc,CAAC,KAAK,CAAC,UAAU,EAAE,YAAY,CAAC,CAAC;IAClF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACnC,OAAO;QACL,kBAAkB,EAAE;YAClB,aAAa,EAAE,kBAAkB;YACjC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC;SACrC;KACF,CAAC;AACJ,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ /**
2
+ * cvh 包的默认导出入口,等价于 `./sdk`(保留两个导出路径以兼容不同引用习惯)。
3
+ */
4
+ export * from "./sdk/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * cvh 包的默认导出入口,等价于 `./sdk`(保留两个导出路径以兼容不同引用习惯)。
3
+ */
4
+ export * from "./sdk/index.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,gBAAgB,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 用户粘贴图片场景的扫描器:复用 Claude Code 官方在用户粘贴图片时会提前落盘的旁路事实。
3
+ *
4
+ * 依赖的实现细节(非官方承诺的稳定 API,best-effort,不做版本防御):
5
+ * `~/.claude/image-cache/<session_id>/<pasteId>.<ext>`
6
+ * 该路径已通过泄露源码 `src/utils/imageStore.ts` 的 `getImageStoreDir()` 源码级确认,
7
+ * `session_id` 与 hook input 里的顶层 `session_id` 字段是同一个值。
8
+ */
9
+ /** 一个已落盘的粘贴图片文件。 */
10
+ export interface PastedImageFile {
11
+ path: string;
12
+ mimeType: string;
13
+ mtimeMs: number;
14
+ }
15
+ /**
16
+ * 拼出指定 session 的 image-cache 目录路径。
17
+ *
18
+ * @param sessionId - Claude Code hook input 里的 `session_id` 字段
19
+ * @returns 该 session 对应的图片缓存目录绝对路径
20
+ */
21
+ export declare const getImageCacheDir: (sessionId: string) => string;
22
+ /**
23
+ * 扫描指定 session 的 image-cache 目录,列出所有图片文件(按修改时间升序)。
24
+ * 目录不存在时(如该 session 从未粘贴过图片)静默返回空数组,不抛错——
25
+ * 这是预期中的常见情况,不是异常。
26
+ *
27
+ * @param sessionId - Claude Code hook input 里的 `session_id` 字段
28
+ * @returns 该 session 目录下所有可识别的图片文件列表
29
+ */
30
+ export declare const scanPastedImages: (sessionId: string) => Promise<PastedImageFile[]>;
31
+ /**
32
+ * 读取指定图片文件的字节内容。
33
+ *
34
+ * @param path - 图片文件绝对路径
35
+ * @returns 图片二进制数据
36
+ */
37
+ export declare const readPastedImageBytes: (path: string) => Promise<Buffer>;