virlen-remote 0.5.0 → 0.7.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 (61) hide show
  1. package/CHANGELOG.md +373 -169
  2. package/README.md +389 -201
  3. package/dist/index.d.ts +10 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +10 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/protocol/agents.d.ts +14 -0
  8. package/dist/protocol/agents.d.ts.map +1 -0
  9. package/dist/protocol/agents.js +14 -0
  10. package/dist/protocol/agents.js.map +1 -0
  11. package/dist/protocol/api.d.ts +370 -1
  12. package/dist/protocol/api.d.ts.map +1 -1
  13. package/dist/protocol/api.js.map +1 -1
  14. package/dist/protocol/compress.d.ts +55 -0
  15. package/dist/protocol/compress.d.ts.map +1 -0
  16. package/dist/protocol/compress.js +60 -0
  17. package/dist/protocol/compress.js.map +1 -0
  18. package/dist/protocol/files.d.ts +261 -0
  19. package/dist/protocol/files.d.ts.map +1 -0
  20. package/dist/protocol/files.js +506 -0
  21. package/dist/protocol/files.js.map +1 -0
  22. package/dist/protocol/host.d.ts +24 -1
  23. package/dist/protocol/host.d.ts.map +1 -1
  24. package/dist/protocol/host.js +9 -1
  25. package/dist/protocol/host.js.map +1 -1
  26. package/dist/protocol/message-files.d.ts +79 -0
  27. package/dist/protocol/message-files.d.ts.map +1 -0
  28. package/dist/protocol/message-files.js +118 -0
  29. package/dist/protocol/message-files.js.map +1 -0
  30. package/dist/protocol/tool-args.d.ts +95 -0
  31. package/dist/protocol/tool-args.d.ts.map +1 -0
  32. package/dist/protocol/tool-args.js +439 -0
  33. package/dist/protocol/tool-args.js.map +1 -0
  34. package/dist/testing/mock-host.d.ts +44 -1
  35. package/dist/testing/mock-host.d.ts.map +1 -1
  36. package/dist/testing/mock-host.js +439 -10
  37. package/dist/testing/mock-host.js.map +1 -1
  38. package/dist/transport/rtc.d.ts +17 -0
  39. package/dist/transport/rtc.d.ts.map +1 -1
  40. package/dist/transport/rtc.js +49 -12
  41. package/dist/transport/rtc.js.map +1 -1
  42. package/dist/transport/signaling.d.ts +12 -1
  43. package/dist/transport/signaling.d.ts.map +1 -1
  44. package/dist/transport/signaling.js +17 -3
  45. package/dist/transport/signaling.js.map +1 -1
  46. package/package.json +69 -69
  47. package/src/index.ts +270 -184
  48. package/src/protocol/agents.ts +13 -0
  49. package/src/protocol/api.ts +974 -615
  50. package/src/protocol/compress.ts +65 -0
  51. package/src/protocol/files.ts +544 -0
  52. package/src/protocol/host.ts +205 -165
  53. package/src/protocol/message-actions.ts +60 -60
  54. package/src/protocol/message-files.ts +161 -0
  55. package/src/protocol/pairing.ts +254 -254
  56. package/src/protocol/tool-args.ts +478 -0
  57. package/src/testing/mock-host.ts +1285 -740
  58. package/src/transport/link-kind.ts +222 -222
  59. package/src/transport/rtc.ts +381 -344
  60. package/src/transport/signaling.ts +331 -317
  61. package/src/transport/transfer-tier.ts +64 -64
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 消息里的**文件引用** —— **两端共用的契约**:能力名 + 引用体 + 校验口径。
3
+ *
4
+ * ## 这是什么(与桌面输入框的「文件附件」同一条口径)
5
+ *
6
+ * 手机端在文件面板里挑一个电脑上的文件,把它的**路径 + 展示元数据**挂到要发的那条消息上;
7
+ * 电脑端把它组装成 `{type:'file'}` 内容块交给引擎。**不搬运文件内容** ——
8
+ * 真正的读取交给 AI 用 `read_file` 工具按需完成(桌面端 `FileAttachment` 的文件头
9
+ * 写着同一条:「只存路径,不拷贝文件内容」)。
10
+ *
11
+ * ## 为什么必须放在共享包里
12
+ *
13
+ * 与 §36 的引用(`message-actions.ts`)同一条理由:它是**两端各表一半**的约定 ——
14
+ * 手机端发 `SendParams.files` 并把 `MessageDTO.files` 渲染成 chip,电脑端把前者组装成内容块、
15
+ * 把后者投影出来。字段名(`path` / `isDir` / `size`)与「路径分隔符统一成 `/`」的归一化
16
+ * 各写一份必然漂移,而漂移的症状是**静默的**:元数据对不上时 chip 显示成另一个东西,
17
+ * 没有任何报错可查。
18
+ *
19
+ * ## 校验为什么也在这里(而不是电脑侧自己写一份)
20
+ *
21
+ * `sanitizeFileRefs` 是**两端唯一的校验口径**:真电脑侧(`virlen-app/src/bridge/host-source.ts`)
22
+ * 与演示宿主(`testing/mock-host.ts`)都调它。若各写一份,mock 与真机会在「什么样的 `files`
23
+ * 算非法」上分叉 —— 而 mock 是单测的唯一参照物,分叉等于测试对着假行为发绿灯。
24
+ *
25
+ * ## 能力位的语义(与 §36 的 `MESSAGE_QUOTE_CAPABILITY` 一致)
26
+ *
27
+ * `MESSAGE_FILE_CAPABILITY` 是**功能标记**,不是权限:文件引用本身就是 `session.send` 的一个
28
+ * 参数(越权防线仍是 ACL 里的 `session.send`)。写进本表只为让手机端知道
29
+ * 「本机电脑端认识 `files`」—— 旧电脑端会把未知字段**静默丢掉**,用户侧的表现是
30
+ * 「我明明附了文件,AI 却当没看见」,且没有任何报错可查。宁可对旧电脑端不显示这个入口。
31
+ */
32
+ import { FILE_NAME_MAX_LEN } from './files'
33
+
34
+ /**
35
+ * 手机端声明它 = **文件面板能给「引用」入口**,并能渲染 `MessageDTO.files` 的 chip。
36
+ * 电脑端列出它 = **本机认识 `SendParams.files` 与 `MessageDTO.files`**。
37
+ */
38
+ export const MESSAGE_FILE_CAPABILITY = 'message.file'
39
+
40
+ /**
41
+ * 一条消息最多能带几个文件引用。
42
+ *
43
+ * 上限的意义有两层:① 超过之后模型也只是「看得到一堆路径」,边际收益为零;
44
+ * ② 它是**载荷上界**(每个引用都在消息里,且会随消息一起持久化 / 导出 / 重放)。
45
+ * 超限**拒整条**而不是截断:截断是静默的 —— 手机端的 chip 还在,用户会以为都带上了。
46
+ */
47
+ export const MESSAGE_FILE_MAX = 20
48
+
49
+ /**
50
+ * 单个引用路径的长度上限(1024 字符)。
51
+ *
52
+ * 与 `MESSAGE_FILE_MAX` 同一条纪律:路径是**手机端给的字符串**,没有上界就等于允许
53
+ * 一个(有 bug 或被改写的)客户端把任意长的内容塞进消息里。正常路径远低于这个数
54
+ * (Windows 的 `MAX_PATH` 是 260)。
55
+ */
56
+ export const MESSAGE_FILE_PATH_MAX = 1024
57
+
58
+ /**
59
+ * 一条**文件引用**(手机端 → 电脑端 → 引擎内容块)。
60
+ *
61
+ * ⚠️ 与 `MessageQuote` 不同,这里**不是快照**:存的是路径,AI 读的那一刻磁盘上是什么就是什么。
62
+ * 这正是「不搬运内容」的代价,也是它的好处(引用的永远是**当前**内容,不会拿一份过期副本
63
+ * 去回答)。桌面端的文件附件同此语义。
64
+ */
65
+ export interface MessageFileRef {
66
+ /** 文件绝对路径(分隔符按契约统一为 `/`,见 `sanitizeFileRefs`)。 */
67
+ path: string
68
+ /** 文件名(含扩展名)—— chip 上显示的就是它。 */
69
+ name: string
70
+ /** 是否为目录(目录也能被引用:模型用 `list_files` 去看里面有什么)。 */
71
+ isDir?: boolean
72
+ /** 字节数(目录没有这一项)。只用于展示,**不作为任何校验依据**(它不是文件的事实来源)。 */
73
+ size?: number
74
+ }
75
+
76
+ /**
77
+ * 校验结果。
78
+ *
79
+ * ⚠️ **刻意不写成判别联合**(`| { ok: true; files } | { ok: false; reason }`):
80
+ * 布尔字面量的判别联合靠 `strictNullChecks` 才能收窄,而消费方 `virlen-app` 的 tsconfig 是
81
+ * `strictNullChecks: false` —— 写成判别联合的话,电脑侧那句 `if (!r.ok) { …r.reason… }`
82
+ * 根本收窄不了(直接 `TS2339: Property 'reason' does not exist`)。契约放在共享包里,
83
+ * 就不能只在一边成立。
84
+ *
85
+ * 不变式:`ok === (reason === '')`(通过时 `reason` 是空串,`files` 是被归一化的引用;
86
+ * 被拒时 `files` 恒为空数组)。
87
+ */
88
+ export interface FileRefSanitizeResult {
89
+ /** 是否通过校验。 */
90
+ ok: boolean
91
+ /** 归一化后的引用;被拒时恒为空数组(调用方那时应该直接抛错)。 */
92
+ files: MessageFileRef[]
93
+ /** 被拒原因(可直接显示给用户);通过时为空串。 */
94
+ reason: string
95
+ }
96
+
97
+ /**
98
+ * 校验并归一化手机传来的文件引用(**电脑侧与演示宿主共用这一份**)。
99
+ *
100
+ * 规则:
101
+ * - 缺省 / `null` → 空数组(不带文件引用是完全正常的一条消息);
102
+ * - 不是数组 / 条目不是对象 / `path` 不是非空字符串 → **整条拒**(返回 `ok:false`);
103
+ * - 路径超过 `MESSAGE_FILE_PATH_MAX`、条目数超过 `MESSAGE_FILE_MAX` → 整条拒;
104
+ * - `name` 缺失或为空 → 用路径末段兜底(电脑侧从内容块投影回来时也会用到这条);
105
+ * - `isDir` / `size` 形状不对 → **丢掉该字段**(它们是展示元数据,说谎也只是 chip 上少一行字),
106
+ * 但路径非法必须拒 —— 那是这条引用**唯一有语义的部分**。
107
+ * - 路径分隔符 `\` → `/`(与全项目的路径口径一致,两侧拼串才不会出现 `a\/b`);
108
+ * - 同一路径只保留第一次出现的(手机端连点两下不该变成两条引用)。
109
+ *
110
+ * ## 为什么非法就整条拒(而不是「丢掉那一条、其余照发」)
111
+ *
112
+ * §36 的教训:**假绿灯比报错难查得多**。丢掉一条引用时,手机端那边的 chip 还在输入框里、
113
+ * 发完消息里也有它 —— 用户会以为带上了,而 AI 从未看到。拒掉整条之后,用户看到的是
114
+ * 一条明确的失败提示,芯片还在,可以改完再发。
115
+ */
116
+ export function sanitizeFileRefs(input: unknown): FileRefSanitizeResult {
117
+ /** 被拒(`files` 恒为空数组 —— 失败就是**整条**失败,没有「部分成功」这个态) */
118
+ const reject = (reason: string): FileRefSanitizeResult => ({ ok: false, files: [], reason })
119
+
120
+ if (input == null) return { ok: true, files: [], reason: '' }
121
+ if (!Array.isArray(input)) return reject('文件引用的形状不对')
122
+ if (input.length > MESSAGE_FILE_MAX) {
123
+ return reject(`一次最多引用 ${MESSAGE_FILE_MAX} 个文件`)
124
+ }
125
+
126
+ const files: MessageFileRef[] = []
127
+ const seen = new Set<string>()
128
+ for (const raw of input) {
129
+ if (typeof raw !== 'object' || raw === null) {
130
+ return reject('文件引用的形状不对')
131
+ }
132
+ const item = raw as Partial<MessageFileRef>
133
+ if (typeof item.path !== 'string' || !item.path.trim()) {
134
+ return reject('文件引用缺少路径')
135
+ }
136
+ // 归一化分隔符:`E:\a\b` → `E:/a/b`(与 `normalizeWorkspace` / 文件面板同一口径)
137
+ const path = item.path.replace(/\\/g, '/')
138
+ if (path.length > MESSAGE_FILE_PATH_MAX) {
139
+ return reject('文件路径过长')
140
+ }
141
+ if (seen.has(path)) continue
142
+ seen.add(path)
143
+
144
+ const name = typeof item.name === 'string' ? item.name.trim() : ''
145
+ files.push({
146
+ path,
147
+ name: (name || baseName(path)).slice(0, FILE_NAME_MAX_LEN),
148
+ ...(typeof item.isDir === 'boolean' ? { isDir: item.isDir } : {}),
149
+ ...(typeof item.size === 'number' && Number.isFinite(item.size) && item.size >= 0
150
+ ? { size: item.size }
151
+ : {}),
152
+ })
153
+ }
154
+ return { ok: true, files, reason: '' }
155
+ }
156
+
157
+ /** 路径末段(`name` 缺失时的兜底;本地实现以免把 `protocol/files` 的整套路径工具拖进来)。 */
158
+ function baseName(path: string): string {
159
+ const parts = path.replace(/\/+$/, '').split('/')
160
+ return parts[parts.length - 1] || path
161
+ }