pi-learning-agent 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 (187) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CONTRIBUTING.md +34 -0
  3. package/LICENSE +21 -0
  4. package/README.md +154 -0
  5. package/SECURITY.md +9 -0
  6. package/docs/architecture.md +75 -0
  7. package/docs/images/learning-workspace.png +0 -0
  8. package/docs/releasing.md +51 -0
  9. package/extension/commands.ts +181 -0
  10. package/extension/index.ts +50 -0
  11. package/extension/learning-session.ts +75 -0
  12. package/extension/runner/code-runner.ts +275 -0
  13. package/extension/server/interaction-broker.ts +345 -0
  14. package/extension/server/learning-server.ts +753 -0
  15. package/extension/server/sse-hub.ts +63 -0
  16. package/extension/state/learning-state.ts +458 -0
  17. package/extension/state/session-persistence.ts +72 -0
  18. package/extension/tools/ask-code.ts +97 -0
  19. package/extension/tools/ask-free-response.ts +88 -0
  20. package/extension/tools/ask-multi-choice.ts +109 -0
  21. package/extension/tools/ask-record-attempt.ts +50 -0
  22. package/extension/tools/ask-single-choice.ts +110 -0
  23. package/extension/tools/index.ts +39 -0
  24. package/extension/tools/tui-presenter.ts +609 -0
  25. package/extension/transcript-sync.ts +197 -0
  26. package/extension/tutor-prompt.ts +64 -0
  27. package/extension/utils/browser.ts +35 -0
  28. package/extension/utils/ids.ts +5 -0
  29. package/extension/utils/validation.ts +138 -0
  30. package/package.json +82 -0
  31. package/shared/learning-state.ts +62 -0
  32. package/shared/protocol.ts +236 -0
  33. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  34. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  35. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  36. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  37. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  38. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  39. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  40. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  41. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  42. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  43. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  44. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  45. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  46. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  47. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  48. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  49. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  50. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  51. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  52. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  53. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  54. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  55. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  56. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  57. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  58. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  59. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  60. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  61. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  62. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  63. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  64. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  65. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  66. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  67. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  68. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  69. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  70. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  71. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  72. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  73. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  74. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  75. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  76. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  77. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  78. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  79. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  80. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  81. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  82. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  83. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  84. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  85. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  86. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  87. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  88. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  89. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  90. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  91. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  92. package/web/dist/assets/abap-08VXUWAP.js +1 -0
  93. package/web/dist/assets/apex-BWPQTe0t.js +1 -0
  94. package/web/dist/assets/azcli-Bc_sGQ0U.js +1 -0
  95. package/web/dist/assets/bat-i0X4ZdIN.js +1 -0
  96. package/web/dist/assets/bicep-B5-_aFwp.js +2 -0
  97. package/web/dist/assets/cameligo-DMUM7wLl.js +1 -0
  98. package/web/dist/assets/clojure-Cm7r79vr.js +1 -0
  99. package/web/dist/assets/codicon-Brq4_Ui5.ttf +0 -0
  100. package/web/dist/assets/coffee-Ba7i2nA0.js +1 -0
  101. package/web/dist/assets/cpp-C7h46wYY.js +1 -0
  102. package/web/dist/assets/csharp-BKxtCVv1.js +1 -0
  103. package/web/dist/assets/csp-bTuwJoIa.js +1 -0
  104. package/web/dist/assets/css-DIMkf-bt.js +3 -0
  105. package/web/dist/assets/css.worker-DRH2OcAG.js +93 -0
  106. package/web/dist/assets/cssMode-BpvqYhpN.js +1 -0
  107. package/web/dist/assets/cypher-CVaqCwHa.js +1 -0
  108. package/web/dist/assets/dart-onAF5SnQ.js +1 -0
  109. package/web/dist/assets/dockerfile-DZFCIeNp.js +1 -0
  110. package/web/dist/assets/ecl-D05T4iGw.js +1 -0
  111. package/web/dist/assets/editor.worker-D9zwrD0f.js +30 -0
  112. package/web/dist/assets/elixir-6RTg0lbw.js +1 -0
  113. package/web/dist/assets/flow9-C5_-GSwl.js +1 -0
  114. package/web/dist/assets/freemarker2-CmTivYCM.js +3 -0
  115. package/web/dist/assets/fsharp-C8Ef5oNN.js +1 -0
  116. package/web/dist/assets/go-C-y9NEjX.js +1 -0
  117. package/web/dist/assets/graphql-fmXr3nnJ.js +1 -0
  118. package/web/dist/assets/handlebars-ClhpojlY.js +1 -0
  119. package/web/dist/assets/hcl-CpzslTdj.js +1 -0
  120. package/web/dist/assets/html-CTqIxFH7.js +1 -0
  121. package/web/dist/assets/html.worker-DQLddT9h.js +506 -0
  122. package/web/dist/assets/htmlMode-BKCTQ4_a.js +1 -0
  123. package/web/dist/assets/index-6N_lnfyE.js +1243 -0
  124. package/web/dist/assets/index-B5x0MzHm.css +1 -0
  125. package/web/dist/assets/ini-sBoK_t0W.js +1 -0
  126. package/web/dist/assets/java-BEtHBSE6.js +1 -0
  127. package/web/dist/assets/javascript-Cp_q6aWP.js +1 -0
  128. package/web/dist/assets/json.worker-D_Dqrls5.js +62 -0
  129. package/web/dist/assets/jsonMode-CMDRrSMX.js +7 -0
  130. package/web/dist/assets/julia-Bri6UV-V.js +1 -0
  131. package/web/dist/assets/kotlin-BOotOW0E.js +1 -0
  132. package/web/dist/assets/less-B9JPFI3C.js +2 -0
  133. package/web/dist/assets/lexon-CfSJPG6W.js +1 -0
  134. package/web/dist/assets/liquid-BFK8j46k.js +1 -0
  135. package/web/dist/assets/lspLanguageFeatures-nT2b0EDJ.js +4 -0
  136. package/web/dist/assets/lua-CsQS60Ue.js +1 -0
  137. package/web/dist/assets/m3-D-oSqn_W.js +1 -0
  138. package/web/dist/assets/markdown-Cimd5fb3.js +1 -0
  139. package/web/dist/assets/mdx-cEJtnt-p.js +1 -0
  140. package/web/dist/assets/mips-CIPQ_RoX.js +1 -0
  141. package/web/dist/assets/msdax-DauUninz.js +1 -0
  142. package/web/dist/assets/mysql-SOo6toE5.js +1 -0
  143. package/web/dist/assets/objective-c-FvmIjYaQ.js +1 -0
  144. package/web/dist/assets/pascal-DrH0SRf2.js +1 -0
  145. package/web/dist/assets/pascaligo-D-ptJ9y-.js +1 -0
  146. package/web/dist/assets/perl-oz_6vUea.js +1 -0
  147. package/web/dist/assets/pgsql-DTj74zXo.js +1 -0
  148. package/web/dist/assets/php-nr791fC2.js +1 -0
  149. package/web/dist/assets/pla-CopQ2nXW.js +1 -0
  150. package/web/dist/assets/postiats-43DmfD33.js +1 -0
  151. package/web/dist/assets/powerquery-D3hlyOfw.js +1 -0
  152. package/web/dist/assets/powershell-DmHpPYUd.js +1 -0
  153. package/web/dist/assets/protobuf-C531GsRP.js +2 -0
  154. package/web/dist/assets/pug-Z5eAx3Zn.js +1 -0
  155. package/web/dist/assets/python-bhI_n2_n.js +1 -0
  156. package/web/dist/assets/qsharp-DkqhCAOL.js +1 -0
  157. package/web/dist/assets/r-BwWrilGY.js +1 -0
  158. package/web/dist/assets/razor-D40-I8aA.js +1 -0
  159. package/web/dist/assets/redis-ClamHrr6.js +1 -0
  160. package/web/dist/assets/redshift-DT7zqm-g.js +1 -0
  161. package/web/dist/assets/restructuredtext-BYgofb2h.js +1 -0
  162. package/web/dist/assets/ruby-DezsRK8O.js +1 -0
  163. package/web/dist/assets/rust-DdL9SqIa.js +1 -0
  164. package/web/dist/assets/sb-CcwsVR0C.js +1 -0
  165. package/web/dist/assets/scala-DHpiXF5c.js +1 -0
  166. package/web/dist/assets/scheme-BeGwcela.js +1 -0
  167. package/web/dist/assets/scss-gp-XZpBa.js +3 -0
  168. package/web/dist/assets/shell-CC2rA5mh.js +1 -0
  169. package/web/dist/assets/solidity-BEEn4gHE.js +1 -0
  170. package/web/dist/assets/sophia-CRfGWb83.js +1 -0
  171. package/web/dist/assets/sparql-D_Lu-MrJ.js +1 -0
  172. package/web/dist/assets/sql-NEE52Syq.js +1 -0
  173. package/web/dist/assets/st-DbInun42.js +1 -0
  174. package/web/dist/assets/swift-Bxkupp3x.js +1 -0
  175. package/web/dist/assets/systemverilog-Bz4Y3fRF.js +1 -0
  176. package/web/dist/assets/tcl-DISqw1ZD.js +1 -0
  177. package/web/dist/assets/ts.worker-DkYoA6bF.js +67738 -0
  178. package/web/dist/assets/tsMode-C9OJ2Aji.js +11 -0
  179. package/web/dist/assets/twig-De2hgUGE.js +1 -0
  180. package/web/dist/assets/typescript-CZ5RbSdB.js +1 -0
  181. package/web/dist/assets/typespec-B8J7ngcE.js +1 -0
  182. package/web/dist/assets/vb-DV3o63ZY.js +1 -0
  183. package/web/dist/assets/wgsl-DpFanUEy.js +298 -0
  184. package/web/dist/assets/workers-qb_EZhxe.js +1 -0
  185. package/web/dist/assets/xml-BdDnAXLl.js +1 -0
  186. package/web/dist/assets/yaml-BSYHoVQy.js +1 -0
  187. package/web/dist/index.html +23 -0
@@ -0,0 +1,753 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
5
+ import path from "node:path";
6
+
7
+ import type { LearningStateStore } from "../state/learning-state.js";
8
+ import {
9
+ CodeRunUnavailableError,
10
+ isSupportedLanguage
11
+ } from "../runner/code-runner.js";
12
+ import type { CodeRunner } from "../runner/code-runner.js";
13
+ import type { InteractionBroker } from "./interaction-broker.js";
14
+ import type {
15
+ InteractionSubmission,
16
+ LearningEvent,
17
+ LearningSessionSnapshot
18
+ } from "../../shared/protocol.js";
19
+ import { SseHub } from "./sse-hub.js";
20
+
21
+ const HOST = "127.0.0.1";
22
+ const MAX_BODY_BYTES = 256 * 1024;
23
+ const MAX_CODE_BYTES = 100 * 1024;
24
+
25
+ /** 规格 25:/api/code/run 的 timeoutMs 取值范围。 */
26
+ const MIN_TIMEOUT_MS = 100;
27
+ const MAX_TIMEOUT_MS = 30_000;
28
+
29
+ export interface LearningServerOptions {
30
+ broker: InteractionBroker;
31
+ state: LearningStateStore;
32
+ now?: () => number;
33
+ /** Injectable for tests; defaults to a random 48-hex-char session token. */
34
+ token?: string;
35
+ /**
36
+ * Root of the built web app (spec 9.2 GET / + assets). Defaults to
37
+ * <repo>/web/dist; injectable for tests.
38
+ */
39
+ staticRoot?: string;
40
+ /**
41
+ * Explicitly enables POST /api/code/run. Omit unless the host provides an
42
+ * appropriately isolated runner; local execution is disabled by default.
43
+ */
44
+ runner?: CodeRunner;
45
+ }
46
+
47
+ /**
48
+ * Local HTTP/SSE server for the web learning workspace (spec 9).
49
+ *
50
+ * Listens on 127.0.0.1 with an OS-chosen port; never on public interfaces.
51
+ * Every /api/* route requires the session token (spec 31); /api/events also
52
+ * accepts ?token= because EventSource cannot send headers.
53
+ *
54
+ * The server starts lazily via start(). GET / serves the built web app from
55
+ * web/dist when present (path traversal guarded), otherwise a small inline
56
+ * placeholder page; everything else is JSON or SSE.
57
+ */
58
+ export class LearningServer {
59
+ private readonly broker: InteractionBroker;
60
+ private readonly state: LearningStateStore;
61
+ private readonly token: string;
62
+ private readonly now: () => number;
63
+ private readonly sseHub = new SseHub();
64
+ private readonly staticRoot: string;
65
+ private readonly runner: CodeRunner | undefined;
66
+ private server: Server | undefined;
67
+ private startPromise: Promise<void> | undefined;
68
+
69
+ constructor(options: LearningServerOptions) {
70
+ this.broker = options.broker;
71
+ this.state = options.state;
72
+ this.token = options.token ?? randomBytes(24).toString("hex");
73
+ this.now = options.now ?? Date.now;
74
+ this.staticRoot =
75
+ options.staticRoot ?? path.resolve(import.meta.dirname, "../../web/dist");
76
+ this.runner = options.runner;
77
+ this.broker.subscribe({
78
+ onPresented: (interaction) =>
79
+ this.sseHub.broadcast({ event: "interaction.presented", interaction }),
80
+ onResolved: (answer) =>
81
+ this.sseHub.broadcast({
82
+ event: "interaction.resolved",
83
+ interactionId: answer.interactionId,
84
+ answer
85
+ }),
86
+ onCancelled: (interactionId, reason) =>
87
+ this.sseHub.broadcast({
88
+ event: "interaction.cancelled",
89
+ interactionId,
90
+ reason
91
+ })
92
+ });
93
+ }
94
+
95
+ /** Start listening on 127.0.0.1:0. Idempotent; resolves once 'listening'. */
96
+ start(): Promise<void> {
97
+ if (this.startPromise !== undefined) {
98
+ return this.startPromise;
99
+ }
100
+
101
+ this.startPromise = new Promise<void>((resolve, reject) => {
102
+ const server = createServer((request, response) => {
103
+ void this.handleRequest(request, response);
104
+ });
105
+ this.server = server;
106
+ server.on("error", (error) => {
107
+ console.error("[pi-learning-server] server error:", error);
108
+ if (this.server === server) {
109
+ this.server = undefined;
110
+ }
111
+ reject(error);
112
+ });
113
+ server.listen(0, HOST, () => {
114
+ if (this.server !== server) {
115
+ server.close();
116
+ return;
117
+ }
118
+ resolve();
119
+ });
120
+ });
121
+
122
+ return this.startPromise;
123
+ }
124
+
125
+ /**
126
+ * Workspace URL, e.g. http://127.0.0.1:54321/?token=<token>.
127
+ * Returns undefined while the server is not running (or has been closed).
128
+ */
129
+ url(): string | undefined {
130
+ const address = this.server?.address();
131
+ if (address === undefined || address === null || typeof address === "string") {
132
+ return undefined;
133
+ }
134
+ return `http://${HOST}:${address.port}/?token=${this.token}`;
135
+ }
136
+
137
+ /**
138
+ * True while at least one SSE client is connected.
139
+ * ponytail: brief false window during a page refresh (old SSE closing,
140
+ * new one not yet connected) is accepted for MVP auto-open decisions.
141
+ */
142
+ hasWebClient(): boolean {
143
+ return this.sseHub.size > 0;
144
+ }
145
+
146
+ /** End all SSE connections and stop the HTTP server. Idempotent. */
147
+ async close(): Promise<void> {
148
+ this.sseHub.close();
149
+ const server = this.server;
150
+ this.server = undefined;
151
+ this.startPromise = undefined;
152
+ if (server === undefined) {
153
+ return;
154
+ }
155
+ await new Promise<void>((resolve) => {
156
+ server.close(() => resolve());
157
+ server.closeAllConnections();
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Broadcast the tutor's final visible assistant text (spec 26). Safe no-op
163
+ * while not started / with no SSE clients: the hub simply has no receivers.
164
+ */
165
+ broadcastTutorMessage(
166
+ role: "assistant",
167
+ text: string,
168
+ messageId: string | undefined,
169
+ done: boolean
170
+ ): void {
171
+ // messageId undefined 时不带该字段(SSE JSON 兼容旧客户端)。
172
+ this.sseHub.broadcast({
173
+ event: "tutor.message",
174
+ role,
175
+ text,
176
+ ...(messageId === undefined ? {} : { messageId }),
177
+ done
178
+ });
179
+ }
180
+
181
+ /** Broadcast tutor waiting/idle status (spec 26). Safe no-op with no clients. */
182
+ broadcastTutorStatus(status: "waiting" | "idle", toolName?: string): void {
183
+ this.sseHub.broadcast(
184
+ toolName === undefined
185
+ ? { event: "tutor.status", status }
186
+ : { event: "tutor.status", status, toolName }
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Broadcast the current learning state as session.updated (spec 16/30). Safe
192
+ * no-op while not started / with no SSE clients: the hub has no receivers.
193
+ */
194
+ broadcastSessionUpdated(): void {
195
+ this.sseHub.broadcast({
196
+ event: "session.updated",
197
+ session: this.sessionSnapshot()
198
+ });
199
+ }
200
+
201
+ private async handleRequest(
202
+ request: IncomingMessage,
203
+ response: ServerResponse
204
+ ): Promise<void> {
205
+ try {
206
+ await this.route(request, response);
207
+ } catch (error) {
208
+ if (response.destroyed) return;
209
+ console.error("[pi-learning-server] request failed:", error);
210
+ if (response.headersSent) {
211
+ response.end();
212
+ return;
213
+ }
214
+ sendJson(response, 500, { ok: false, message: "Internal server error." });
215
+ }
216
+ }
217
+
218
+ private async route(
219
+ request: IncomingMessage,
220
+ response: ServerResponse
221
+ ): Promise<void> {
222
+ const url = new URL(request.url ?? "/", `http://${HOST}`);
223
+ const { pathname } = url;
224
+ const method = request.method ?? "GET";
225
+
226
+ if (method === "GET" && pathname === "/") {
227
+ await this.serveStatic(response, "/");
228
+ return;
229
+ }
230
+
231
+ if (!pathname.startsWith("/api/")) {
232
+ if (method === "GET") {
233
+ await this.serveStatic(response, pathname);
234
+ return;
235
+ }
236
+ sendJson(response, 404, { ok: false, message: "Not found." });
237
+ return;
238
+ }
239
+
240
+ const allowQueryToken = pathname === "/api/events";
241
+ if (!this.isAuthorized(request, allowQueryToken)) {
242
+ sendJson(response, 401, { ok: false, message: "Unauthorized." });
243
+ return;
244
+ }
245
+
246
+ if (method === "GET" && pathname === "/api/health") {
247
+ sendJson(response, 200, {
248
+ ok: true,
249
+ service: "pi-learning-agent",
250
+ capabilities: { codeExecution: this.runner !== undefined }
251
+ });
252
+ return;
253
+ }
254
+
255
+ if (method === "GET" && pathname === "/api/session") {
256
+ sendJson(response, 200, this.sessionSnapshot());
257
+ return;
258
+ }
259
+
260
+ if (method === "GET" && pathname === "/api/interactions/pending") {
261
+ sendJson(response, 200, { interactions: this.broker.getPending() });
262
+ return;
263
+ }
264
+
265
+ if (method === "GET" && pathname === "/api/events") {
266
+ this.handleSse(response);
267
+ return;
268
+ }
269
+
270
+ const submitMatch = /^\/api\/interactions\/([^/]+)\/submit$/u.exec(pathname);
271
+ if (method === "POST" && submitMatch !== null) {
272
+ await this.handleSubmit(request, response, submitMatch[1] ?? "");
273
+ return;
274
+ }
275
+
276
+ // 规格 9.2:跳过题目(仅 allowSkip 的题目)。与 submit 一样走 broker
277
+ // 解析,返回结构化 skipped answer(规格 7.5)。
278
+ const skipMatch = /^\/api\/interactions\/([^/]+)\/skip$/u.exec(pathname);
279
+ if (method === "POST" && skipMatch !== null) {
280
+ await this.handleSkip(request, response, skipMatch[1] ?? "");
281
+ return;
282
+ }
283
+
284
+ // 规格 25:只有宿主显式注入 runner 时才开放。临时目录、env 白名单、
285
+ // 超时和输出截断都不是安全沙箱,因此正式扩展默认返回 403。结果只回给
286
+ // 学习者自测,不进入 learning_ask_code 的 tool result。
287
+ if (method === "POST" && pathname === "/api/code/run") {
288
+ await this.handleCodeRun(request, response);
289
+ return;
290
+ }
291
+
292
+ sendJson(response, 404, { ok: false, message: "Not found." });
293
+ }
294
+
295
+ /**
296
+ * Serve a file from the built web app root (spec 9.2). Paths are resolved
297
+ * against staticRoot and must stay inside it (blocks ../ and encoded
298
+ * traversal); GET / defaults to index.html and falls back to the inline
299
+ * placeholder page when no build exists (spec 33: frontend bundle missing).
300
+ */
301
+ private async serveStatic(response: ServerResponse, pathname: string): Promise<void> {
302
+ let decoded: string;
303
+ try {
304
+ decoded = decodeURIComponent(pathname);
305
+ } catch {
306
+ sendJson(response, 404, { ok: false, message: "Not found." });
307
+ return;
308
+ }
309
+ const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/u, "");
310
+ const resolved = path.resolve(this.staticRoot, relative);
311
+ if (resolved !== this.staticRoot && !resolved.startsWith(this.staticRoot + path.sep)) {
312
+ // Resolved outside the dist root: path traversal attempt.
313
+ sendJson(response, 404, { ok: false, message: "Not found." });
314
+ return;
315
+ }
316
+
317
+ let content: Buffer;
318
+ try {
319
+ content = await readFile(resolved);
320
+ } catch {
321
+ if (decoded === "/") {
322
+ sendIndex(response);
323
+ return;
324
+ }
325
+ sendJson(response, 404, { ok: false, message: "Not found." });
326
+ return;
327
+ }
328
+
329
+ const contentType =
330
+ CONTENT_TYPES[path.extname(resolved).toLowerCase()] ??
331
+ "application/octet-stream";
332
+ response.writeHead(200, {
333
+ "Content-Type": contentType,
334
+ "Content-Length": content.length
335
+ });
336
+ response.end(content);
337
+ }
338
+
339
+ private isAuthorized(request: IncomingMessage, allowQueryToken: boolean): boolean {
340
+ if (request.headers.authorization === `Bearer ${this.token}`) {
341
+ return true;
342
+ }
343
+ if (allowQueryToken) {
344
+ const url = new URL(request.url ?? "/", `http://${HOST}`);
345
+ return url.searchParams.get("token") === this.token;
346
+ }
347
+ return false;
348
+ }
349
+
350
+ private sessionSnapshot(): LearningSessionSnapshot {
351
+ const snapshot = this.state.snapshot();
352
+ return {
353
+ learningMode: snapshot.enabled,
354
+ ...(snapshot.course === undefined ? {} : { course: { ...snapshot.course } }),
355
+ ...(snapshot.topic === undefined ? {} : { topic: { ...snapshot.topic } }),
356
+ phase: snapshot.phase,
357
+ concepts: Object.values(snapshot.concepts)
358
+ };
359
+ }
360
+
361
+ private handleSse(response: ServerResponse): void {
362
+ response.writeHead(200, {
363
+ "Content-Type": "text/event-stream",
364
+ "Cache-Control": "no-cache",
365
+ Connection: "keep-alive",
366
+ "X-Accel-Buffering": "no"
367
+ });
368
+ response.write(
369
+ `event: connected\ndata: ${JSON.stringify({ event: "connected", time: this.now() })}\n\n`
370
+ );
371
+ this.sseHub.addClient(response);
372
+ }
373
+
374
+ private async handleSubmit(
375
+ request: IncomingMessage,
376
+ response: ServerResponse,
377
+ rawPathId: string
378
+ ): Promise<void> {
379
+ let interactionId: string;
380
+ try {
381
+ interactionId = decodeURIComponent(rawPathId);
382
+ } catch {
383
+ sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
384
+ return;
385
+ }
386
+ if (interactionId === "") {
387
+ sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
388
+ return;
389
+ }
390
+
391
+ let rawBody: string;
392
+ try {
393
+ rawBody = await readBody(request);
394
+ } catch (error) {
395
+ if (error instanceof BodyTooLargeError) {
396
+ sendJson(response, 413, { ok: false, message: "Request body too large." });
397
+ return;
398
+ }
399
+ throw error;
400
+ }
401
+
402
+ let body: unknown;
403
+ try {
404
+ body = JSON.parse(rawBody) as unknown;
405
+ } catch {
406
+ sendJson(response, 400, {
407
+ ok: false,
408
+ message: "Request body must be valid JSON."
409
+ });
410
+ return;
411
+ }
412
+ if (!isRecord(body)) {
413
+ sendJson(response, 400, { ok: false, message: "Request body must be a JSON object." });
414
+ return;
415
+ }
416
+ if (body.interactionId !== interactionId) {
417
+ sendJson(response, 400, {
418
+ ok: false,
419
+ message: "interactionId does not match the URL."
420
+ });
421
+ return;
422
+ }
423
+ if (!("answer" in body)) {
424
+ sendJson(response, 400, { ok: false, message: "answer is required." });
425
+ return;
426
+ }
427
+ if (typeof body.clientTimestamp !== "number") {
428
+ sendJson(response, 400, {
429
+ ok: false,
430
+ message: "clientTimestamp must be a number."
431
+ });
432
+ return;
433
+ }
434
+
435
+ const submission: InteractionSubmission = {
436
+ interactionId,
437
+ answer: body.answer,
438
+ clientTimestamp: body.clientTimestamp
439
+ };
440
+ const result = this.broker.submit(submission);
441
+ if (!result.ok) {
442
+ const status =
443
+ result.reason === "not_found"
444
+ ? 404
445
+ : result.reason === "already_resolved"
446
+ ? 409
447
+ : 400;
448
+ sendJson(response, status, {
449
+ ok: false,
450
+ reason: result.reason,
451
+ message: result.message
452
+ });
453
+ return;
454
+ }
455
+
456
+ sendJson(response, 200, { ok: true, answer: result.answer });
457
+ }
458
+
459
+ /**
460
+ * POST /api/interactions/:id/skip (spec 9.2): learner declines the question.
461
+ * Body: { interactionId } (no answer). Delegate to broker.skip so the
462
+ * pending tool promise resolves with a structured skipped answer.
463
+ */
464
+ private async handleSkip(
465
+ request: IncomingMessage,
466
+ response: ServerResponse,
467
+ rawPathId: string
468
+ ): Promise<void> {
469
+ let interactionId: string;
470
+ try {
471
+ interactionId = decodeURIComponent(rawPathId);
472
+ } catch {
473
+ sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
474
+ return;
475
+ }
476
+ if (interactionId === "") {
477
+ sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
478
+ return;
479
+ }
480
+
481
+ let rawBody: string;
482
+ try {
483
+ rawBody = await readBody(request);
484
+ } catch (error) {
485
+ if (error instanceof BodyTooLargeError) {
486
+ sendJson(response, 413, { ok: false, message: "Request body too large." });
487
+ return;
488
+ }
489
+ throw error;
490
+ }
491
+
492
+ let body: unknown;
493
+ try {
494
+ body = JSON.parse(rawBody) as unknown;
495
+ } catch {
496
+ sendJson(response, 400, {
497
+ ok: false,
498
+ message: "Request body must be valid JSON."
499
+ });
500
+ return;
501
+ }
502
+ if (!isRecord(body)) {
503
+ sendJson(response, 400, { ok: false, message: "Request body must be a JSON object." });
504
+ return;
505
+ }
506
+ if (body.interactionId !== interactionId) {
507
+ sendJson(response, 400, {
508
+ ok: false,
509
+ message: "interactionId does not match the URL."
510
+ });
511
+ return;
512
+ }
513
+
514
+ const result = this.broker.skip(interactionId);
515
+ if (!result.ok) {
516
+ const status =
517
+ result.reason === "not_found"
518
+ ? 404
519
+ : result.reason === "already_resolved"
520
+ ? 409
521
+ : 400;
522
+ sendJson(response, status, {
523
+ ok: false,
524
+ reason: result.reason,
525
+ message: result.message
526
+ });
527
+ return;
528
+ }
529
+
530
+ sendJson(response, 200, { ok: true, answer: result.answer });
531
+ }
532
+
533
+ /**
534
+ * POST /api/code/run (spec 25): run learner code locally for self-testing.
535
+ * Validation: language must be in the program-defined whitelist, code is a
536
+ * string ≤ 100KB, timeoutMs optional within [100, 30000] (default 8000).
537
+ * 503 when the runtime binary is missing; other failures bubble to the 500
538
+ * handler in handleRequest.
539
+ */
540
+ private async handleCodeRun(
541
+ request: IncomingMessage,
542
+ response: ServerResponse
543
+ ): Promise<void> {
544
+ const runner = this.runner;
545
+ if (runner === undefined) {
546
+ sendJson(response, 403, {
547
+ ok: false,
548
+ reason: "code_execution_disabled",
549
+ message: "Local code execution is disabled for this session."
550
+ });
551
+ return;
552
+ }
553
+
554
+ let rawBody: string;
555
+ try {
556
+ rawBody = await readBody(request);
557
+ } catch (error) {
558
+ if (error instanceof BodyTooLargeError) {
559
+ sendJson(response, 413, { ok: false, message: "Request body too large." });
560
+ return;
561
+ }
562
+ throw error;
563
+ }
564
+
565
+ let body: unknown;
566
+ try {
567
+ body = JSON.parse(rawBody) as unknown;
568
+ } catch {
569
+ sendJson(response, 400, {
570
+ ok: false,
571
+ message: "Request body must be valid JSON."
572
+ });
573
+ return;
574
+ }
575
+ if (!isRecord(body)) {
576
+ sendJson(response, 400, { ok: false, message: "Request body must be a JSON object." });
577
+ return;
578
+ }
579
+ if (typeof body.language !== "string" || !isSupportedLanguage(body.language)) {
580
+ sendJson(response, 400, {
581
+ ok: false,
582
+ message: `Unsupported language: ${String(body.language)}.`
583
+ });
584
+ return;
585
+ }
586
+ if (typeof body.code !== "string") {
587
+ sendJson(response, 400, { ok: false, message: "code must be a string." });
588
+ return;
589
+ }
590
+ if (Buffer.byteLength(body.code, "utf8") > MAX_CODE_BYTES) {
591
+ sendJson(response, 400, {
592
+ ok: false,
593
+ message: `code must be at most ${MAX_CODE_BYTES} bytes.`
594
+ });
595
+ return;
596
+ }
597
+ let timeoutMs = 8000;
598
+ if (body.timeoutMs !== undefined) {
599
+ if (
600
+ typeof body.timeoutMs !== "number" ||
601
+ !Number.isFinite(body.timeoutMs) ||
602
+ body.timeoutMs < MIN_TIMEOUT_MS ||
603
+ body.timeoutMs > MAX_TIMEOUT_MS
604
+ ) {
605
+ sendJson(response, 400, {
606
+ ok: false,
607
+ message: `timeoutMs must be a number between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}.`
608
+ });
609
+ return;
610
+ }
611
+ timeoutMs = body.timeoutMs;
612
+ }
613
+
614
+ const controller = new AbortController();
615
+ const onClose = () => controller.abort();
616
+ response.once("close", onClose);
617
+ if (response.destroyed) controller.abort();
618
+ try {
619
+ const result = await runner.run({
620
+ language: body.language,
621
+ code: body.code,
622
+ timeoutMs
623
+ }, controller.signal);
624
+ sendJson(response, 200, { ok: true, result });
625
+ } catch (error) {
626
+ if (error instanceof CodeRunUnavailableError) {
627
+ sendJson(response, 503, {
628
+ ok: false,
629
+ reason: "runtime_unavailable",
630
+ message: error.message
631
+ });
632
+ return;
633
+ }
634
+ throw error;
635
+ } finally {
636
+ response.off("close", onClose);
637
+ }
638
+ }
639
+ }
640
+
641
+ class BodyTooLargeError extends Error {
642
+ constructor() {
643
+ super("Request body exceeds 256KB.");
644
+ this.name = "BodyTooLargeError";
645
+ }
646
+ }
647
+
648
+ /** Read the request body, rejecting as soon as it exceeds MAX_BODY_BYTES. */
649
+ function readBody(request: IncomingMessage): Promise<string> {
650
+ const declaredLength = Number(request.headers["content-length"] ?? 0);
651
+ return new Promise((resolve, reject) => {
652
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
653
+ reject(new BodyTooLargeError());
654
+ return;
655
+ }
656
+
657
+ const chunks: Buffer[] = [];
658
+ let received = 0;
659
+ let overflow = false;
660
+ request.on("data", (chunk: Buffer) => {
661
+ if (overflow) {
662
+ return;
663
+ }
664
+ received += chunk.length;
665
+ if (received > MAX_BODY_BYTES) {
666
+ overflow = true;
667
+ reject(new BodyTooLargeError());
668
+ return;
669
+ }
670
+ chunks.push(chunk);
671
+ });
672
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
673
+ request.on("error", (error) => reject(error));
674
+ });
675
+ }
676
+
677
+ function sendJson(response: ServerResponse, status: number, body: unknown): void {
678
+ const payload = JSON.stringify(body);
679
+ response.writeHead(status, {
680
+ "Content-Type": "application/json; charset=utf-8",
681
+ "Content-Length": Buffer.byteLength(payload)
682
+ });
683
+ response.end(payload);
684
+ }
685
+
686
+ function sendIndex(response: ServerResponse): void {
687
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
688
+ response.end(INDEX_HTML);
689
+ }
690
+
691
+ function isRecord(value: unknown): value is Record<string, unknown> {
692
+ return typeof value === "object" && value !== null && !Array.isArray(value);
693
+ }
694
+
695
+ /** Minimal content-type map for the built web app assets. */
696
+ const CONTENT_TYPES: Record<string, string> = {
697
+ ".html": "text/html; charset=utf-8",
698
+ ".js": "text/javascript; charset=utf-8",
699
+ ".css": "text/css; charset=utf-8",
700
+ ".svg": "image/svg+xml",
701
+ ".map": "application/json; charset=utf-8",
702
+ ".woff2": "font/woff2",
703
+ ".ico": "image/x-icon",
704
+ ".json": "application/json; charset=utf-8",
705
+ ".png": "image/png"
706
+ };
707
+
708
+ /**
709
+ * Minimal placeholder page (spec 9.2 GET /), used only when no built web app
710
+ * is present. Reads the token from the URL, keeps it in sessionStorage, then
711
+ * verifies /api/health. The real React app (web/dist) replaces this after
712
+ * `npm run build:web`.
713
+ */
714
+ const INDEX_HTML = `<!doctype html>
715
+ <html lang="en">
716
+ <head>
717
+ <meta charset="utf-8" />
718
+ <title>Pi Learning Session</title>
719
+ <style>
720
+ body { font-family: system-ui, sans-serif; margin: 2rem; }
721
+ #status { font-variant-numeric: tabular-nums; }
722
+ </style>
723
+ </head>
724
+ <body>
725
+ <h1>Pi Learning Session</h1>
726
+ <p>Connected to Pi Learning Session</p>
727
+ <p id="status">Checking server...</p>
728
+ <script>
729
+ (function () {
730
+ var params = new URLSearchParams(location.search);
731
+ var token = params.get("token");
732
+ if (token) {
733
+ sessionStorage.setItem("pi_learning_token", token);
734
+ }
735
+ var saved = sessionStorage.getItem("pi_learning_token");
736
+ var status = document.getElementById("status");
737
+ if (!saved) {
738
+ status.textContent = "No token found in URL.";
739
+ return;
740
+ }
741
+ fetch("/api/health", {
742
+ headers: { Authorization: "Bearer " + saved }
743
+ })
744
+ .then(function (res) {
745
+ status.textContent = res.ok ? "Server: ok" : "Server: HTTP " + res.status;
746
+ })
747
+ .catch(function () {
748
+ status.textContent = "Server unreachable.";
749
+ });
750
+ })();
751
+ </script>
752
+ </body>
753
+ </html>`;