cotutor 0.3.0 → 0.4.1

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 (209) hide show
  1. package/LICENSE +9 -7
  2. package/NOTICE +3 -3
  3. package/README.md +65 -18
  4. package/avatars/chinese.webp +0 -0
  5. package/avatars/english.webp +0 -0
  6. package/avatars/math.webp +0 -0
  7. package/bin/cotutor.js +13 -3
  8. package/cards/photo/card.css +10 -3
  9. package/cards/photo/card.md +2 -2
  10. package/cards/tutor/card.css +13 -0
  11. package/cards/tutor/card.md +2 -1
  12. package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +1 -1
  13. package/dist/app/activity-eval.js +3 -2
  14. package/dist/app/cli/activities.d.ts +8 -1
  15. package/dist/app/cli/activities.js +42 -4
  16. package/dist/app/cli/cert.d.ts +11 -0
  17. package/dist/app/cli/cert.js +36 -9
  18. package/dist/app/cli/chalk/browser.d.ts +2 -2
  19. package/dist/app/cli/chalk/browser.js +22 -7
  20. package/dist/app/cli/chalk/main.js +3 -2
  21. package/dist/app/cli/doctor.d.ts +11 -5
  22. package/dist/app/cli/doctor.js +156 -181
  23. package/dist/app/cli/firewall.d.ts +24 -0
  24. package/dist/app/cli/firewall.js +79 -0
  25. package/dist/app/cli/init.js +8 -5
  26. package/dist/app/cli/main.js +379 -27
  27. package/dist/app/cli/migrate.d.ts +2 -11
  28. package/dist/app/cli/migrate.js +1 -59
  29. package/dist/app/cli/port-holder.d.ts +16 -0
  30. package/dist/app/cli/port-holder.js +34 -0
  31. package/dist/app/cli/port.d.ts +37 -0
  32. package/dist/app/cli/port.js +116 -0
  33. package/dist/app/cli/sandbox.d.ts +25 -0
  34. package/dist/app/cli/sandbox.js +166 -0
  35. package/dist/app/cli/serve-log.d.ts +13 -0
  36. package/dist/app/cli/serve-log.js +90 -0
  37. package/dist/app/cli/serve.d.ts +18 -4
  38. package/dist/app/cli/serve.js +78 -8
  39. package/dist/app/cli/service-windows.d.ts +50 -0
  40. package/dist/app/cli/service-windows.js +117 -0
  41. package/dist/app/cli/service.d.ts +152 -0
  42. package/dist/app/cli/service.js +443 -0
  43. package/dist/app/cli/skeleton.d.ts +1 -1
  44. package/dist/app/cli/skeleton.js +13 -13
  45. package/dist/app/cli/skills.d.ts +5 -1
  46. package/dist/app/cli/skills.js +14 -4
  47. package/dist/app/cli/tray-icons.d.ts +11 -0
  48. package/dist/app/cli/tray-icons.js +87 -0
  49. package/dist/app/cli/tray-ids.d.ts +6 -0
  50. package/dist/app/cli/tray-ids.js +6 -0
  51. package/dist/app/cli/tray-mac.d.ts +8 -0
  52. package/dist/app/cli/tray-mac.js +140 -0
  53. package/dist/app/cli/tray-run.d.ts +1 -0
  54. package/dist/app/cli/tray-run.js +42 -0
  55. package/dist/app/cli/tray-windows.d.ts +5 -0
  56. package/dist/app/cli/tray-windows.js +166 -0
  57. package/dist/app/cli/tray.d.ts +236 -0
  58. package/dist/app/cli/tray.js +670 -0
  59. package/dist/app/cli/usage.d.ts +1 -1
  60. package/dist/app/cli/usage.js +11 -6
  61. package/dist/app/dictation/route.js +3 -2
  62. package/dist/app/http/app.d.ts +5 -10
  63. package/dist/app/http/app.js +47 -98
  64. package/dist/app/http/avatars.d.ts +17 -0
  65. package/dist/app/http/avatars.js +35 -0
  66. package/dist/app/http/dev-tutor-files.js +1 -1
  67. package/dist/app/http/parent-home.d.ts +14 -0
  68. package/dist/app/http/parent-home.js +69 -6
  69. package/dist/app/http/parent-look.d.ts +2 -0
  70. package/dist/app/http/parent-look.js +151 -0
  71. package/dist/app/http/raw-view.js +3 -3
  72. package/dist/app/http/voice-preview.d.ts +4 -0
  73. package/dist/app/http/voice-preview.js +34 -0
  74. package/dist/app/machine/bailian.d.ts +0 -2
  75. package/dist/app/machine/bailian.js +5 -6
  76. package/dist/app/machine/update.d.ts +25 -0
  77. package/dist/app/machine/update.js +88 -0
  78. package/dist/app/mock/mock-live.js +1 -1
  79. package/dist/app/mock/mock.d.ts +4 -2
  80. package/dist/app/mock/mock.js +20 -12
  81. package/dist/app/replay.js +4 -4
  82. package/dist/app/skills/home-doc.js +1 -1
  83. package/dist/app/workspace.d.ts +9 -4
  84. package/dist/app/workspace.js +33 -10
  85. package/dist/app/ws/route.d.ts +8 -2
  86. package/dist/app/ws/route.js +7 -3
  87. package/dist/board/parse.d.ts +0 -2
  88. package/dist/board/parse.js +0 -6
  89. package/dist/board/presentation.d.ts +9 -15
  90. package/dist/board/presentation.js +8 -10
  91. package/dist/brain/api-tools.js +2 -1
  92. package/dist/brain/api.d.ts +12 -17
  93. package/dist/brain/api.js +16 -33
  94. package/dist/brain/index.d.ts +23 -71
  95. package/dist/brain/index.js +6 -69
  96. package/dist/brain/log.d.ts +5 -0
  97. package/dist/brain/log.js +22 -0
  98. package/dist/brain/runtime.d.ts +30 -0
  99. package/dist/brain/runtime.js +23 -0
  100. package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
  101. package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
  102. package/dist/brain/stream.d.ts +5 -5
  103. package/dist/brain/stream.js +7 -7
  104. package/dist/brain/transcript.d.ts +3 -3
  105. package/dist/brain/transcript.js +6 -6
  106. package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
  107. package/dist/brain/types.js +14 -0
  108. package/dist/chalk/glyphs/skeleton.d.ts +3 -3
  109. package/dist/chalk/glyphs/skeleton.js +1 -1
  110. package/dist/lib/index.d.ts +1 -1
  111. package/dist/lib/index.js +1 -1
  112. package/dist/memory/diary.js +3 -3
  113. package/dist/realtime/bailian.d.ts +1 -1
  114. package/dist/realtime/bailian.js +3 -2
  115. package/dist/realtime/preview.js +1 -1
  116. package/dist/realtime/voices.d.ts +2 -0
  117. package/dist/realtime/voices.js +57 -57
  118. package/dist/runtime/activities.d.ts +13 -1
  119. package/dist/runtime/activities.js +28 -2
  120. package/dist/runtime/activity-state.js +2 -2
  121. package/dist/runtime/agent-file.d.ts +3 -3
  122. package/dist/runtime/agent-file.js +3 -3
  123. package/dist/runtime/context/tutor-rules.d.ts +2 -2
  124. package/dist/runtime/context/tutor-rules.js +3 -3
  125. package/dist/runtime/home/home.d.ts +12 -1
  126. package/dist/runtime/home/home.js +8 -1
  127. package/dist/runtime/home/store.d.ts +1 -1
  128. package/dist/runtime/home/store.js +36 -21
  129. package/dist/runtime/journal/mix.d.ts +15 -6
  130. package/dist/runtime/journal/mix.js +60 -19
  131. package/dist/runtime/journal/recorder.d.ts +58 -13
  132. package/dist/runtime/journal/recorder.js +106 -16
  133. package/dist/runtime/journal/store.d.ts +4 -4
  134. package/dist/runtime/journal/store.js +6 -6
  135. package/dist/runtime/kid-input.js +2 -3
  136. package/dist/runtime/live/call.d.ts +12 -0
  137. package/dist/runtime/live/call.js +51 -18
  138. package/dist/runtime/live/turns.js +1 -1
  139. package/dist/runtime/runner.d.ts +9 -55
  140. package/dist/runtime/runner.js +52 -173
  141. package/dist/scenes/paint/painter.js +4 -3
  142. package/dist/schema/config.d.ts +43 -22
  143. package/dist/schema/config.js +45 -47
  144. package/dist/schema/conversation.d.ts +6 -0
  145. package/dist/schema/home.d.ts +11 -1
  146. package/dist/schema/home.js +7 -2
  147. package/dist/schema/workspace.d.ts +27 -0
  148. package/dist/schema/workspace.js +28 -0
  149. package/dist/shared/default-port.d.ts +2 -0
  150. package/dist/shared/default-port.js +2 -0
  151. package/dist/shared/qianwen.d.ts +14 -10
  152. package/dist/shared/qianwen.js +26 -22
  153. package/dist/shared/site-url.d.ts +2 -0
  154. package/dist/shared/site-url.js +5 -0
  155. package/dist/shared/upgrade.d.ts +9 -0
  156. package/dist/shared/upgrade.js +23 -0
  157. package/dist/ui/dev/dev-page.js +25 -252
  158. package/dist/ui/dev/dev-theme.d.ts +1 -1
  159. package/dist/ui/dev/dev-theme.js +1 -1
  160. package/dist/ui/dev/voice-gallery-page.js +1 -1
  161. package/dist/ui/kid/board-mark.d.ts +2 -2
  162. package/dist/ui/kid/board-mark.js +2 -2
  163. package/dist/ui/kid/home-tiles.d.ts +14 -0
  164. package/dist/ui/kid/home-tiles.js +44 -0
  165. package/dist/ui/kid/kid-page.js +68 -103
  166. package/dist/ui/kid/voice-ui.d.ts +1 -1
  167. package/dist/ui/kid/voice-ui.js +1 -1
  168. package/dist/ui/kid/word-emoji.js +5 -3
  169. package/dist/ui/parent/parent-home-page.js +337 -7
  170. package/dist/ui/parent/parent-page.js +6 -2
  171. package/dist/ui/parent/parent-settings-page.d.ts +1 -0
  172. package/dist/ui/parent/parent-settings-page.js +133 -0
  173. package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
  174. package/dist/ui/{dev → parent}/qr-page.js +19 -14
  175. package/dist/ui/shared/brand.d.ts +3 -1
  176. package/dist/ui/shared/brand.js +3 -1
  177. package/grades/grade-2/README.md +1 -1
  178. package/grades/grade-2/VALIDATION.md +1 -1
  179. package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
  180. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  181. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  182. package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
  183. package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
  184. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  185. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  186. package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
  187. package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
  188. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  189. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  190. package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
  191. package/package.json +6 -2
  192. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
  193. package/skills/cotutor-board/references/photo.md +2 -2
  194. package/skills/cotutor-home/SKILL.md +1 -0
  195. package/skills/cotutor-home/references/tutor.md +2 -1
  196. package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
  197. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
  198. package/skills/cotutor-tutor/SKILL.md +5 -13
  199. package/dist/brain/cli/adapters.d.ts +0 -60
  200. package/dist/brain/cli/adapters.js +0 -91
  201. package/dist/brain/cli/qwen.d.ts +0 -11
  202. package/dist/brain/cli/qwen.js +0 -141
  203. package/dist/brain/cli/run-plan.d.ts +0 -59
  204. package/dist/brain/cli/run-plan.js +0 -71
  205. package/dist/brain/cli/run.d.ts +0 -35
  206. package/dist/brain/cli/run.js +0 -256
  207. package/dist/brain/cli/types.js +0 -3
  208. package/dist/brain/cli/warm.d.ts +0 -53
  209. package/dist/brain/cli/warm.js +0 -103
@@ -0,0 +1,16 @@
1
+ import type { ServiceHost } from './service.ts';
2
+ export interface PortHolder {
3
+ pid: number;
4
+ name: string;
5
+ }
6
+ /** lsof -Fpc 的输出:一行 p<pid>,一行 c<命令名> */
7
+ export declare function parseLsof(out: string): PortHolder | null;
8
+ /**
9
+ * 谁在监听(Windows):只认对端端口是 0,不看 State。2026-11-06 Windows 11 build 26300 上监听的 socket 状态是乱的:
10
+ * netstat 显示 CLOSED、FIN_WAIT_1,-State Listen 查不到,读 State 还可能抛异常
11
+ */
12
+ export declare function holderScript(port: number): string;
13
+ /** 上面那段的输出;坏的、空的当不知道 */
14
+ export declare function parseHolder(text: string): PortHolder | null;
15
+ /** 谁在监听这个端口;查不到(别人的进程、没装 lsof)→ null */
16
+ export declare function portHolder(host: Pick<ServiceHost, 'platform' | 'run'>, port: number): Promise<PortHolder | null>;
@@ -0,0 +1,34 @@
1
+ /** 谁在监听这个端口:port.ts(端口的规矩)和托盘(tray.ts)都用,单独一个文件,托盘进程才不用加载 workspace 与 zod */
2
+ import { psArgs } from "./service-windows.js";
3
+ /** lsof -Fpc 的输出:一行 p<pid>,一行 c<命令名> */
4
+ export function parseLsof(out) {
5
+ const pid = /^p(\d+)$/m.exec(out)?.[1];
6
+ const name = /^c(.+)$/m.exec(out)?.[1];
7
+ return pid && name ? { pid: Number(pid), name } : null;
8
+ }
9
+ /**
10
+ * 谁在监听(Windows):只认对端端口是 0,不看 State。2026-11-06 Windows 11 build 26300 上监听的 socket 状态是乱的:
11
+ * netstat 显示 CLOSED、FIN_WAIT_1,-State Listen 查不到,读 State 还可能抛异常
12
+ */
13
+ export function holderScript(port) {
14
+ return [
15
+ `$c = Get-NetTCPConnection -LocalPort ${port} | Where-Object { $_.RemotePort -eq 0 } | Select-Object -First 1`,
16
+ 'if ($c) { $p = Get-Process -Id $c.OwningProcess; ConvertTo-Json -Compress @{ pid = [int]$c.OwningProcess; name = $p.ProcessName } }',
17
+ ].join('\n');
18
+ }
19
+ /** 上面那段的输出;坏的、空的当不知道 */
20
+ export function parseHolder(text) {
21
+ try {
22
+ const o = JSON.parse(text.trim());
23
+ return typeof o.pid === 'number' && typeof o.name === 'string' && o.name ? { pid: o.pid, name: o.name } : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** 谁在监听这个端口;查不到(别人的进程、没装 lsof)→ null */
30
+ export async function portHolder(host, port) {
31
+ if (host.platform === 'win32')
32
+ return parseHolder((await host.run('powershell.exe', psArgs(holderScript(port)))).stdout);
33
+ return parseLsof((await host.run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'])).stdout);
34
+ }
@@ -0,0 +1,37 @@
1
+ import { holderScript, parseHolder, parseLsof, portHolder, type PortHolder } from './port-holder.ts';
2
+ import { type ServiceHost } from './service.ts';
3
+ export declare const PORT_CANDIDATES: readonly number[];
4
+ /** sandbox 用哪个端口 */
5
+ export declare const sandboxPort: (port: number) => number;
6
+ export type PortState = 'free' | 'busy' | 'denied';
7
+ /**
8
+ * 真绑一下就放开,看系统让不让用这个端口(被占主要靠 portState 先问的那一声)。Windows 上绑通配地址会弹防火墙的窗(还会立刻建 Block 规则),探一下不该弹,所以绑 127.0.0.1,那边也没有低端口的限制;
9
+ * macOS 反过来,只能绑通配;别的系统同 macOS
10
+ */
11
+ export declare function tryBind(port: number, platform?: NodeJS.Platform): Promise<PortState>;
12
+ /** 本机这个端口上有人答应吗 */
13
+ export declare function answersLocally(port: number, timeoutMs?: number): Promise<boolean>;
14
+ /** 先问一声再绑:别的程序只占着 127.0.0.1:443 时,绑 0.0.0.0:443 照样成功(2026-11-06 macOS、Windows 都试过),光绑看不出被占 */
15
+ export declare function portState(port: number): Promise<PortState>;
16
+ export interface PortChoice {
17
+ port: number;
18
+ skipped: {
19
+ port: number;
20
+ why: PortState | 'taken';
21
+ }[];
22
+ }
23
+ /** taken:别的 workspace 写着的端口,连同它们的 sandbox 都让开;选不出来报用法错误 */
24
+ export declare function choosePort(taken: Iterable<number>, probe?: (port: number) => Promise<PortState>, candidates?: readonly number[]): Promise<PortChoice>;
25
+ /** init 里 cotutor.json 那一行的说明 */
26
+ export declare function choiceNote(c: PortChoice): string;
27
+ /** 这台电脑上别的 workspace 写着的端口(~/cotutor/ 下的,加上用户配置指的那个):还没起的也要让开 */
28
+ export declare function knownPorts(): number[];
29
+ export { holderScript, parseHolder, parseLsof, portHolder, type PortHolder };
30
+ export interface Holder {
31
+ /** 「nginx(pid 12)」;不知道是谁 → null */
32
+ text: string | null;
33
+ cotutor: 'service' | 'serve' | null;
34
+ }
35
+ export declare function whoHolds(host: Pick<ServiceHost, 'platform' | 'run'>, port: number): Promise<Holder>;
36
+ /** serve 绑不上时那句话;service = 后台服务里(不重试,说怎么腾) */
37
+ export declare function bindFailure(code: 'EACCES' | 'EADDRINUSE', port: number, who: Holder | null, service: boolean): string;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * 端口的规矩:新 workspace 只在 cotutor init 选一次,写进 cotutor.json,以后不变。
3
+ * 不变是因为平板上的主屏幕图标、页面存的设置都绑在「协议 + 主机 + 端口」上:换一次,平板要重新扫码、重加图标。所以运行时被占了只报错说清楚,不像 Vite 那样自动换下一个。
4
+ * 依次试 443(地址里不用写端口)、8443、8445…:系统不让用、被占着、留给了别的孩子就看下一个,不按系统写分支。
5
+ * macOS 普通用户绑得上 0.0.0.0:443,绑 127.0.0.1:443 反而不行(Mojave 起如此,苹果没写文档,2026-11-06 在 27.2 上试过);Linux 默认 1024 以下要 root,落到 8443。
6
+ * 隔一个号给 sandbox 留的:它的端口是真 workspace 的 +1。
7
+ * 旧 workspace 写着什么就是什么;cotutor.json 没写端口的走 DEFAULT_PORT,不跟着这里变。
8
+ */
9
+ import { createConnection, createServer } from 'node:net';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { DEFAULT_PORT } from "../../schema/index.js";
13
+ import { holderScript, parseHolder, parseLsof, portHolder } from "./port-holder.js";
14
+ import { probeHealth } from "./service.js";
15
+ import { CONFIG_FILE, USER_CONFIG, UsageError, expandPath, homeWorkspaces, readJson } from "../workspace.js";
16
+ export const PORT_CANDIDATES = [443, ...Array.from({ length: 9 }, (_, i) => 8443 + i * 2)];
17
+ /** sandbox 用哪个端口 */
18
+ export const sandboxPort = (port) => (port === 65535 ? port - 1 : port + 1);
19
+ /**
20
+ * 真绑一下就放开,看系统让不让用这个端口(被占主要靠 portState 先问的那一声)。Windows 上绑通配地址会弹防火墙的窗(还会立刻建 Block 规则),探一下不该弹,所以绑 127.0.0.1,那边也没有低端口的限制;
21
+ * macOS 反过来,只能绑通配;别的系统同 macOS
22
+ */
23
+ export function tryBind(port, platform = process.platform) {
24
+ return new Promise((resolve) => {
25
+ const s = createServer();
26
+ s.once('error', (e) => resolve(e.code === 'EACCES' ? 'denied' : 'busy'));
27
+ s.listen(port, platform === 'win32' ? '127.0.0.1' : '0.0.0.0', () => s.close(() => resolve('free')));
28
+ });
29
+ }
30
+ /** 本机这个端口上有人答应吗 */
31
+ export function answersLocally(port, timeoutMs = 400) {
32
+ return new Promise((resolve) => {
33
+ const c = createConnection({ host: '127.0.0.1', port });
34
+ const finish = (yes) => { c.destroy(); resolve(yes); };
35
+ c.setTimeout(timeoutMs, () => finish(false));
36
+ c.once('connect', () => finish(true));
37
+ c.once('error', () => finish(false));
38
+ });
39
+ }
40
+ /** 先问一声再绑:别的程序只占着 127.0.0.1:443 时,绑 0.0.0.0:443 照样成功(2026-11-06 macOS、Windows 都试过),光绑看不出被占 */
41
+ export async function portState(port) {
42
+ return (await answersLocally(port)) ? 'busy' : tryBind(port);
43
+ }
44
+ /** taken:别的 workspace 写着的端口,连同它们的 sandbox 都让开;选不出来报用法错误 */
45
+ export async function choosePort(taken, probe = portState, candidates = PORT_CANDIDATES) {
46
+ const used = new Set();
47
+ for (const p of taken) {
48
+ used.add(p);
49
+ used.add(sandboxPort(p));
50
+ }
51
+ const skipped = [];
52
+ for (const port of candidates) {
53
+ if (used.has(port) || used.has(sandboxPort(port))) {
54
+ skipped.push({ port, why: 'taken' });
55
+ continue;
56
+ }
57
+ const state = await probe(port);
58
+ if (state === 'free')
59
+ return { port, skipped };
60
+ skipped.push({ port, why: state });
61
+ }
62
+ throw new UsageError(`端口 ${candidates.join('、')} 都用不了:cotutor init 加 --port <n> 指定一个`);
63
+ }
64
+ /** init 里 cotutor.json 那一行的说明 */
65
+ export function choiceNote(c) {
66
+ const WHY = { busy: '被占着', denied: '这个系统不让普通用户用', taken: '留给了另一个孩子', free: '' };
67
+ const first = c.skipped[0];
68
+ const head = c.port === 443 ? '端口 443,地址里不用写端口' : `端口 ${c.port}${first ? `(${first.port} ${WHY[first.why]})` : ''}`;
69
+ return `${head};选定了就不再变,改了平板要重新扫码`;
70
+ }
71
+ /** 这台电脑上别的 workspace 写着的端口(~/cotutor/ 下的,加上用户配置指的那个):还没起的也要让开 */
72
+ export function knownPorts() {
73
+ const roots = new Set(homeWorkspaces());
74
+ try {
75
+ const user = readJson(USER_CONFIG);
76
+ if (typeof user?.workspace === 'string' && user.workspace)
77
+ roots.add(expandPath(user.workspace, homedir()));
78
+ }
79
+ catch { /* 用户配置坏了 doctor 去报 */ }
80
+ const ports = [];
81
+ for (const root of roots) {
82
+ try {
83
+ const raw = readJson(join(root, CONFIG_FILE));
84
+ if (!raw)
85
+ continue;
86
+ ports.push(typeof raw.server?.port === 'number' ? raw.server.port : DEFAULT_PORT);
87
+ }
88
+ catch { /* 配置坏了:那边 doctor 去报,这里当它没有 */ }
89
+ }
90
+ return ports;
91
+ }
92
+ export { holderScript, parseHolder, parseLsof, portHolder };
93
+ export async function whoHolds(host, port) {
94
+ const h = await probeHealth(port);
95
+ const pid = h?.pid ? `(pid ${h.pid})` : '';
96
+ if (h)
97
+ return h.mode === 'service' ? { text: `cotutor 的后台服务${pid}`, cotutor: 'service' } : { text: `另一个 cotutor serve${pid}`, cotutor: 'serve' };
98
+ const p = await portHolder(host, port);
99
+ return { text: p ? `${p.name}(pid ${p.pid})` : null, cotutor: null };
100
+ }
101
+ const CHANGE_PORT = '要换端口就改 cotutor.json 的 server.port,平板要重新扫码、重加主屏幕图标';
102
+ /** serve 绑不上时那句话;service = 后台服务里(不重试,说怎么腾) */
103
+ export function bindFailure(code, port, who, service) {
104
+ if (code === 'EACCES') {
105
+ const head = `系统不让这个用户监听端口 ${port}(Linux 上 1024 以下要管理员权限,Windows 上可能落在系统保留的端口段)`;
106
+ return service ? `${head},后台服务先不起。${CHANGE_PORT},然后 cotutor service start` : `${head}。--port 临时换一个;${CHANGE_PORT}`;
107
+ }
108
+ const head = `端口 ${port} 被占着${who?.text ? `:${who.text} 在用` : ''}`;
109
+ if (service)
110
+ return who?.cotutor ? `${head},后台服务先不起;关掉它以后 cotutor service start` : `${head},后台服务先不起;腾出来以后 cotutor service start。${CHANGE_PORT}`;
111
+ if (who?.cotutor === 'service')
112
+ return `${head}。要在终端里调试先 cotutor service stop(cotutor service status 看状态);也可以 --port 临时换一个`;
113
+ if (who?.cotutor === 'serve')
114
+ return `${head}。关掉那个终端里的它;也可以 --port 临时换一个`;
115
+ return `${head}。关掉它,或 --port 临时换一个;${CHANGE_PORT}`;
116
+ }
@@ -0,0 +1,25 @@
1
+ import { type Workspace } from '../../schema/workspace.ts';
2
+ import { type ResolveOptions } from '../workspace.ts';
3
+ export interface SandboxResult {
4
+ /** sandbox 的根:后面的命令都当 --workspace 用它 */
5
+ root: string;
6
+ /** 从哪个真 workspace 分出来的 */
7
+ from: string;
8
+ /** 这次新分的(第一次,或 --fresh) */
9
+ created: boolean;
10
+ /** 分的时候 paths 怎么处理的(指在外面、只读照旧用的那几个) */
11
+ notes: string[];
12
+ }
13
+ export interface SandboxOptions extends ResolveOptions {
14
+ fresh?: boolean;
15
+ now?: Date;
16
+ }
17
+ /** ~/cotutor/ray → ~/cotutor/.sandbox/ray */
18
+ export declare const sandboxRootOf: (real: string) => string;
19
+ export declare function openSandbox(override: string | undefined, opts?: SandboxOptions): Promise<SandboxResult>;
20
+ /**
21
+ * sandbox 的 cotutor.json:在真的那份原文上改(_note 这些机器不认识的键照留)。
22
+ * 标题加「试用 · 」、端口 +1、证书改绝对路径、自动记账关掉;paths 里每个配过的角色照它在真 workspace 里落在哪,改成指 sandbox 里对应的那处(写相对的,sandbox 挪了也不断)。
23
+ * 落在 workspace 与 vault 外面的:只读的材料照旧指那份,captures 改回缺省,其余会写的拦下——试用写进去的会混进真的。
24
+ */
25
+ export declare function sandboxConfig(raw: Record<string, unknown>, ws: Workspace, box: string, notes: string[]): Record<string, unknown>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * --sandbox:命令换到真 workspace 旁边的试用 workspace(位置、拷什么、cotutor.json 改哪几处见 src/schema/workspace.ts 的 SANDBOX_*)。
3
+ * 没有就分一个;有了就接着用(试过的对话留着,能接着聊、能试记账);--fresh 重新分。
4
+ * 重新分先分到临时目录,成了才换掉旧的:中途断了旧的还在,半截的不会被当成分好的(标记最后写)。
5
+ * 已经在 sandbox 里(cwd 在里面、--workspace 指过来)就用它;--fresh 从它记着的真 workspace 重新分,不套娃。
6
+ */
7
+ import { copyFile, cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
9
+ import { PATH_ROLES, ROLE_DEFAULTS, WORKSPACE_ROLES } from "../../schema/index.js";
10
+ import { SANDBOX_DIR, SANDBOX_KEEP_MACHINE, SANDBOX_MARK, SANDBOX_SKIP, SANDBOX_TITLE, SANDBOX_VAULT } from "../../schema/workspace.js";
11
+ import { CONFIG_FILE, UsageError, expandPath, loadWorkspace, parseConfig, readJson, readSandboxMark, redactHome, resolveRoot } from "../workspace.js";
12
+ import { sandboxPort } from "./port.js";
13
+ import { DIRS, LEDGER_FILES } from "./skeleton.js";
14
+ /** vault 侧指在外面也不怕的角色:只读的材料,老师不往里写 */
15
+ const READ_ONLY_ROLES = ['textbooks', 'reference'];
16
+ /** ~/cotutor/ray → ~/cotutor/.sandbox/ray */
17
+ export const sandboxRootOf = (real) => join(dirname(real), SANDBOX_DIR, basename(real));
18
+ /** p 是 dir 本身或在它里面 */
19
+ function inside(p, dir) {
20
+ const r = relative(dir, p);
21
+ return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
22
+ }
23
+ async function exists(p) {
24
+ return stat(p).then(() => true, () => false);
25
+ }
26
+ export async function openSandbox(override, opts = {}) {
27
+ const { root } = resolveRoot(override, opts);
28
+ const here = readSandboxMark(root);
29
+ const real = here ? here.from : root;
30
+ const box = here ? root : sandboxRootOf(real);
31
+ const existing = here ?? ((await exists(box)) ? readSandboxMark(box) : null);
32
+ if (!existing && (await exists(box)))
33
+ throw new UsageError(`${redactHome(box)} 已经有了,但不是 sandbox(没有 ${SANDBOX_MARK}),不动它;挪走再试`);
34
+ if (existing && !opts.fresh) {
35
+ const moved = await followPort(box, existing.from);
36
+ return { root: box, from: existing.from, created: false, notes: moved ? [moved] : [] };
37
+ }
38
+ if (!(await exists(join(real, CONFIG_FILE))))
39
+ throw new UsageError(`这个 sandbox 是从 ${redactHome(real)} 分出来的,那里已经没有 workspace 了,重新分不了`);
40
+ const notes = await fork(real, box, opts);
41
+ return { root: box, from: real, created: true, notes };
42
+ }
43
+ /** 试用的端口永远是真 workspace 的 +1:真的搬了端口,试用下次打开跟过去,不用记两套规矩;返回说明,没动 → null */
44
+ async function followPort(box, real) {
45
+ const file = join(box, CONFIG_FILE);
46
+ try {
47
+ const realRaw = readJson(join(real, CONFIG_FILE));
48
+ const raw = readJson(file);
49
+ if (realRaw === null || raw === null)
50
+ return null;
51
+ const want = sandboxPort(parseConfig(realRaw, join(real, CONFIG_FILE)).server.port);
52
+ const server = (raw.server && typeof raw.server === 'object' ? raw.server : {});
53
+ if (server.port === want)
54
+ return null;
55
+ await writeFile(file, `${JSON.stringify({ ...raw, server: { ...server, port: want } }, null, 2)}\n`);
56
+ return `真 workspace 换了端口,试用跟着改成 ${want}`;
57
+ }
58
+ catch {
59
+ return null; // 真的那份坏了或没了:试用照旧
60
+ }
61
+ }
62
+ /** 真 workspace → box:根下照拷(跳过状态),外面的 vault 拷进来,补骨架,写改过的 cotutor.json,最后写标记再换上 */
63
+ async function fork(real, box, opts) {
64
+ const ws = loadWorkspace(real, opts);
65
+ if (ws.sandbox)
66
+ throw new UsageError(`${redactHome(real)} 本身就是 sandbox,不从它再分`);
67
+ const vault = ws.paths.vault;
68
+ if (!(await stat(vault).then((s) => s.isDirectory(), () => false)))
69
+ throw new UsageError(`vault 不在:${redactHome(vault)}(cotutor.json 的 paths.vault);先修好真 workspace(cotutor doctor)再分`);
70
+ const notes = [];
71
+ const config = sandboxConfig(readJson(ws.files.config), ws, box, notes);
72
+ parseConfig(config, join(box, CONFIG_FILE));
73
+ const skip = new Set(SANDBOX_SKIP.map((n) => join(real, n)));
74
+ if (inside(ws.paths.captures, real))
75
+ skip.add(ws.paths.captures);
76
+ const keep = (src) => basename(src) !== '.git' && !skip.has(src);
77
+ const tmp = `${box}.tmp-${process.pid}`;
78
+ await rm(tmp, { recursive: true, force: true });
79
+ await mkdir(tmp, { recursive: true });
80
+ try {
81
+ for (const name of await readdir(real)) {
82
+ const src = join(real, name);
83
+ if (name === CONFIG_FILE || !keep(src))
84
+ continue;
85
+ // 链照原样拷(.claude/skills → ../.agents/skills 在 sandbox 里还是指自己那份)
86
+ await cp(src, join(tmp, name), { recursive: true, verbatimSymlinks: true, filter: keep });
87
+ }
88
+ await mkdir(join(tmp, '.cotutor'), { recursive: true });
89
+ for (const f of SANDBOX_KEEP_MACHINE)
90
+ await copyFile(join(real, '.cotutor', f), join(tmp, '.cotutor', f)).catch(() => undefined);
91
+ if (!inside(vault, real)) {
92
+ if (await exists(join(tmp, SANDBOX_VAULT)))
93
+ throw new UsageError(`workspace 根下已经有 ${SANDBOX_VAULT},外面的 vault 拷不进 sandbox;把它改个名再分`);
94
+ await cp(vault, join(tmp, SANDBOX_VAULT), { recursive: true, verbatimSymlinks: true, filter: (src) => basename(src) !== '.git' });
95
+ }
96
+ for (const d of DIRS)
97
+ await mkdir(join(tmp, d), { recursive: true });
98
+ for (const f of LEDGER_FILES)
99
+ await writeFile(join(tmp, f), '');
100
+ await writeFile(join(tmp, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
101
+ const mark = { from: real, vault, forkedAt: (opts.now ?? new Date()).toISOString() };
102
+ await writeFile(join(tmp, SANDBOX_MARK), `${JSON.stringify(mark, null, 2)}\n`);
103
+ // 旧的到这里才删:openSandbox 查过它带标记
104
+ if (await exists(box))
105
+ await rm(box, { recursive: true, force: true });
106
+ await rename(tmp, box);
107
+ }
108
+ catch (err) {
109
+ await rm(tmp, { recursive: true, force: true });
110
+ throw err;
111
+ }
112
+ return notes;
113
+ }
114
+ /**
115
+ * sandbox 的 cotutor.json:在真的那份原文上改(_note 这些机器不认识的键照留)。
116
+ * 标题加「试用 · 」、端口 +1、证书改绝对路径、自动记账关掉;paths 里每个配过的角色照它在真 workspace 里落在哪,改成指 sandbox 里对应的那处(写相对的,sandbox 挪了也不断)。
117
+ * 落在 workspace 与 vault 外面的:只读的材料照旧指那份,captures 改回缺省,其余会写的拦下——试用写进去的会混进真的。
118
+ */
119
+ export function sandboxConfig(raw, ws, box, notes) {
120
+ const real = ws.root;
121
+ const vault = ws.paths.vault;
122
+ const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
123
+ const cfg = structuredClone(raw);
124
+ cfg.title = `${SANDBOX_TITLE} · ${ws.config.title}`;
125
+ const port = ws.config.server.port;
126
+ const server = { ...(isObj(raw.server) ? raw.server : {}), port: sandboxPort(port) };
127
+ const https = ws.config.server.https;
128
+ if (https)
129
+ server.https = { cert: expandPath(https.cert, real), key: expandPath(https.key, real) };
130
+ cfg.server = server;
131
+ // 自动记账关掉:试出来的话题不值得每个花一轮老师去记;要试记账,家长端点「记账」或在 sandbox 的 cotutor.json 打开
132
+ if (ws.config.vault.autoBookkeep) {
133
+ cfg.vault = { ...(isObj(raw.vault) ? raw.vault : {}), autoBookkeep: false };
134
+ notes.push('自动记账在 sandbox 里关了(试出来的话题不值得花钱记);要试记账,家长端点「记账」,或在 sandbox 的 cotutor.json 打开 vault.autoBookkeep');
135
+ }
136
+ const paths = isObj(raw.paths) ? { ...raw.paths } : {};
137
+ const vaultInside = inside(vault, real);
138
+ const newVault = vaultInside ? join(box, relative(real, vault)) : join(box, SANDBOX_VAULT);
139
+ if (!vaultInside)
140
+ paths.vault = SANDBOX_VAULT;
141
+ else if ('vault' in paths)
142
+ paths.vault = relative(real, vault) || '.';
143
+ for (const role of Object.keys(paths)) {
144
+ if (role === 'vault')
145
+ continue;
146
+ const abs = ws.paths[role];
147
+ const workspaceSide = PATH_ROLES.includes(role) && WORKSPACE_ROLES.includes(role);
148
+ const target = inside(abs, vault) ? join(newVault, relative(vault, abs)) : inside(abs, real) ? join(box, relative(real, abs)) : null;
149
+ if (target) {
150
+ paths[role] = relative(workspaceSide ? box : newVault, target) || '.';
151
+ }
152
+ else if (READ_ONLY_ROLES.includes(role)) {
153
+ paths[role] = abs;
154
+ notes.push(`paths.${role} 在 workspace 与 vault 外面(${redactHome(abs)}):只读,照旧用那份`);
155
+ }
156
+ else if (workspaceSide) {
157
+ delete paths[role];
158
+ notes.push(`paths.${role} 在 workspace 外面(${redactHome(abs)}):sandbox 里改用缺省的 ${ROLE_DEFAULTS[role]}`);
159
+ }
160
+ else {
161
+ throw new UsageError(`paths.${role} 指到 workspace 与 vault 外面(${redactHome(abs)}),试用时写进去的会混进真的。改成 vault 里的路径再分,或者另建一个试用 workspace(cotutor init <slug> --dir <dir>)`);
162
+ }
163
+ }
164
+ cfg.paths = paths;
165
+ return cfg;
166
+ }
@@ -0,0 +1,13 @@
1
+ export declare const LOG_KEEP_DAYS = 14;
2
+ /** 删 keepDays 天以前的(按文件名的日期,不看修改时间);返回删了哪些 */
3
+ export declare function pruneLogs(dir: string, now: Date, keepDays?: number): string[];
4
+ /** 今天写的是哪个文件 */
5
+ export declare const logFileFor: (dir: string, now: Date) => string;
6
+ /**
7
+ * 把 process.stdout / stderr 改写进按天的文件(不再写到原来的地方,免得 launchd.log 里再有一份);返回撤掉的函数(测试用)。
8
+ * 一行开头加「时:分:秒 」;一次写进来的不一定是整行,接着上一次的不再加
9
+ */
10
+ export declare function teeLogs(dir: string, opts?: {
11
+ now?: () => Date;
12
+ keepDays?: number;
13
+ }): () => void;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 后台服务的日志(cotutor serve --log-dir <目录>,cotutor service 装的就带它):stdout / stderr 改写进 <目录>/<日期>.log,一天一个,每行带时分秒;
3
+ * 留 LOG_KEEP_DAYS 天,起来时、换天时删更早的。一跑几个星期,不按天切就一个文件越长越大(launchd、systemd 都不替你切)。
4
+ * 起来之前的输出(node 自己报的、模块没加载上)还是走原来的 stdout / stderr:macOS 是 launchd 接着的 <目录>/launchd.log,Linux 是 journal。
5
+ */
6
+ import { closeSync, mkdirSync, openSync, readdirSync, rmSync, writeSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { localDate } from "../../shared/time.js";
9
+ export const LOG_KEEP_DAYS = 14;
10
+ const LOG_RE = /^(\d{4}-\d{2}-\d{2})\.log$/;
11
+ /** 删 keepDays 天以前的(按文件名的日期,不看修改时间);返回删了哪些 */
12
+ export function pruneLogs(dir, now, keepDays = LOG_KEEP_DAYS) {
13
+ const oldest = localDate(new Date(now.getTime() - (keepDays - 1) * 86_400_000));
14
+ const gone = [];
15
+ let names = [];
16
+ try {
17
+ names = readdirSync(dir);
18
+ }
19
+ catch {
20
+ return gone;
21
+ }
22
+ for (const f of names) {
23
+ const m = LOG_RE.exec(f);
24
+ if (m && m[1] < oldest) {
25
+ rmSync(join(dir, f), { force: true });
26
+ gone.push(f);
27
+ }
28
+ }
29
+ return gone;
30
+ }
31
+ /** 今天写的是哪个文件 */
32
+ export const logFileFor = (dir, now) => join(dir, `${localDate(now)}.log`);
33
+ /**
34
+ * 把 process.stdout / stderr 改写进按天的文件(不再写到原来的地方,免得 launchd.log 里再有一份);返回撤掉的函数(测试用)。
35
+ * 一行开头加「时:分:秒 」;一次写进来的不一定是整行,接着上一次的不再加
36
+ */
37
+ export function teeLogs(dir, opts = {}) {
38
+ const now = opts.now ?? (() => new Date());
39
+ mkdirSync(dir, { recursive: true });
40
+ let day = '';
41
+ let fd = null;
42
+ let atLineStart = true;
43
+ const open = (d) => {
44
+ const today = localDate(d);
45
+ if (fd !== null && today === day)
46
+ return fd;
47
+ if (fd !== null)
48
+ closeSync(fd);
49
+ day = today;
50
+ fd = openSync(logFileFor(dir, d), 'a');
51
+ pruneLogs(dir, d, opts.keepDays);
52
+ return fd;
53
+ };
54
+ const write = (chunk, encoding) => {
55
+ const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) || chunk instanceof Uint8Array ? Buffer.from(chunk).toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk);
56
+ if (!text)
57
+ return;
58
+ const d = now();
59
+ const stamp = `${[d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':')} `;
60
+ let out = '';
61
+ for (const part of text.split(/(?<=\n)/)) {
62
+ out += (atLineStart ? stamp : '') + part;
63
+ atLineStart = part.endsWith('\n');
64
+ }
65
+ try {
66
+ writeSync(open(d), out);
67
+ }
68
+ catch { /* 写不了日志不能把服务带倒 */ }
69
+ };
70
+ const wrap = (stream) => {
71
+ const original = stream.write;
72
+ stream.write = ((chunk, encoding, cb) => {
73
+ write(chunk, encoding);
74
+ const done = typeof encoding === 'function' ? encoding : cb;
75
+ if (typeof done === 'function')
76
+ queueMicrotask(() => done());
77
+ return true;
78
+ });
79
+ return () => { stream.write = original; };
80
+ };
81
+ const undo = [wrap(process.stdout), wrap(process.stderr)];
82
+ open(now());
83
+ return () => {
84
+ for (const u of undo)
85
+ u();
86
+ if (fd !== null)
87
+ closeSync(fd);
88
+ fd = null;
89
+ };
90
+ }
@@ -1,13 +1,17 @@
1
1
  import { type Server } from 'node:http';
2
- import type { ListenInfo } from '../../ui/dev/qr-page.ts';
2
+ import { siteUrl } from '../../shared/site-url.ts';
3
+ import type { ListenInfo } from '../../ui/parent/qr-page.ts';
3
4
  import { type AppContext } from '../http/app.ts';
4
5
  import { type ResolveOptions, type Workspace } from '../workspace.ts';
6
+ export { siteUrl };
5
7
  export interface ServeOptions extends ResolveOptions {
6
8
  workspace?: string;
7
9
  port?: number;
8
10
  host?: string;
9
11
  /** 强制走 HTTP(哪怕证书在) */
10
12
  http?: boolean;
13
+ /** 有事时拦着睡眠;缺省开 */
14
+ awake?: boolean;
11
15
  }
12
16
  export interface ServeResult {
13
17
  server: Server;
@@ -17,8 +21,8 @@ export interface ServeResult {
17
21
  https: boolean;
18
22
  /** 本机名与局域网 IPv4 各一条 */
19
23
  urls: string[];
20
- /** 扫码页:在这台电脑上打开的那条(localhost,证书里签了) */
21
- qrPage: string;
24
+ /** 家长端首页:在这台电脑上打开的那条(localhost,证书里签了);serve 只打印它,扫码在它里面 */
25
+ parentPage: string;
22
26
  warnings: string[];
23
27
  }
24
28
  /** 证书路径:cotutor.json 的 server.https 优先(相对 workspace 根),否则机器级 ~/.config/cotutor/certs/;都没有 → null */
@@ -28,10 +32,20 @@ export declare function httpsFiles(ws: Workspace): {
28
32
  } | null;
29
33
  /** 局域网 IPv4(iPad / iPhone 用它访问;.local 名不一定解析得到,有些设备还会挑中走不通的 IPv6) */
30
34
  export declare function lanAddresses(): string[];
35
+ /**
36
+ * 这台电脑在局域网里的名字(不带 .local)。macOS 读「本地主机名」(系统设置 › 通用 › 共享 里那个,就是 Bonjour 名,改了系统才变);
37
+ * 别的系统用 os.hostname()。读不到退回 os.hostname()。serve、证书、二维码都从这里拿
38
+ */
39
+ export declare function computerName(platform?: NodeJS.Platform, read?: () => string, fallback?: string): string;
31
40
  /** 本机的 mDNS 名(与 cotutor cert 签的那条同一个写法) */
32
- export declare function localName(host?: string): string;
41
+ export declare function localName(name?: string): string;
33
42
  /** 证书认不认这个主机名;读不了当作不认 */
34
43
  export declare function certCovers(certFile: string, host: string): boolean;
35
44
  /** 扫码页要的三样;listen 之后才知道真端口 */
36
45
  export declare function listenInfo(https: boolean, port: number): ListenInfo;
37
46
  export declare function serveWorkspace(opts?: ServeOptions): Promise<ServeResult>;
47
+ /**
48
+ * busy() 为真就起一个拦闲置睡眠的子进程,为假就停;serve 没了它自己退:macOS 是 caffeinate -i -w <pid>,Windows 是一段 PowerShell(service-windows.ts 的 awakeScript);Linux 什么都不做。
49
+ * 合盖照样睡(那是家长的事,iPad 那篇写着);返回停掉的函数
50
+ */
51
+ export declare function holdAwake(busy: () => boolean, platform?: NodeJS.Platform, everyMs?: number): () => void;