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,197 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Subset of LearningServer used by transcript sync (spec 26). */
4
+ export interface TranscriptSyncServer {
5
+ broadcastTutorMessage(
6
+ role: "assistant",
7
+ text: string,
8
+ messageId: string | undefined,
9
+ done: boolean
10
+ ): void;
11
+ broadcastTutorStatus(status: "waiting" | "idle", toolName?: string): void;
12
+ }
13
+
14
+ /** Injectable scheduler/clock for throttling tests. */
15
+ export interface TranscriptSyncOptions {
16
+ throttleMs?: number;
17
+ /** Opaque timer handle (number in browsers/tests, NodeJS.Timeout in Node). */
18
+ setTimeoutFn?: (fn: () => void, ms: number) => unknown;
19
+ clearTimeoutFn?: (handle: unknown) => void;
20
+ }
21
+
22
+ /**
23
+ * Extract the learner-visible assistant text from a message (spec 26).
24
+ * Only role === "assistant" messages qualify; string content is used as-is,
25
+ * array content is concatenated from `text` parts only (images and other
26
+ * parts are skipped). Returns undefined for non-assistant or non-text
27
+ * content. Reasoning/thinking is never part of the visible text.
28
+ */
29
+ export function extractAssistantText(message: {
30
+ role?: string;
31
+ content?: unknown;
32
+ }): string | undefined {
33
+ if (message.role !== "assistant") {
34
+ return undefined;
35
+ }
36
+ const { content } = message;
37
+ if (typeof content === "string") {
38
+ return content;
39
+ }
40
+ if (!Array.isArray(content)) {
41
+ return undefined;
42
+ }
43
+ const text = content
44
+ .filter(
45
+ (part): part is { type: "text"; text: string } =>
46
+ typeof part === "object" &&
47
+ part !== null &&
48
+ (part as { type?: unknown }).type === "text" &&
49
+ typeof (part as { text?: unknown }).text === "string"
50
+ )
51
+ .map((part) => part.text)
52
+ .join("");
53
+ return text;
54
+ }
55
+
56
+ /**
57
+ * Key a pi message for streaming correlation. AgentMessage has no id field;
58
+ * AssistantMessage.responseId is the provider response id, stable across all
59
+ * message_update/message_end frames of one streamed response. When absent,
60
+ * fall back to the current streaming key (set by message_update) or a local
61
+ * per-sync sequence — Pi delivers one message's events sequentially, so the
62
+ * end frame always follows the updates of the same message.
63
+ */
64
+ function messageKey(responseId: unknown, current: string | undefined): string {
65
+ if (typeof responseId === "string" && responseId !== "") {
66
+ return responseId;
67
+ }
68
+ return current ?? "";
69
+ }
70
+
71
+ /**
72
+ * Sync the tutor's visible transcript to web clients (spec 26): streamed
73
+ * assistant text on message_update (throttled), final text on message_end,
74
+ * learning-tool waiting status, and idle signals.
75
+ *
76
+ * Ordering guarantee: the done:true frame for a messageId is always last.
77
+ * On message_end any throttled-but-unflushed partial frame for that message
78
+ * is dropped (the final frame carries the complete text, so the client
79
+ * converges to the same content — simpler than force-flushing).
80
+ */
81
+ export function registerTranscriptSync(
82
+ pi: ExtensionAPI,
83
+ server: TranscriptSyncServer,
84
+ options: TranscriptSyncOptions = {}
85
+ ): void {
86
+ const throttleMs = options.throttleMs ?? 100;
87
+ const schedule: (fn: () => void, ms: number) => unknown =
88
+ options.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms));
89
+ const cancel: (handle: unknown) => void =
90
+ options.clearTimeoutFn ?? ((handle) => clearTimeout(handle as NodeJS.Timeout));
91
+
92
+ // ponytail: single in-flight streaming message tracked; Pi streams one
93
+ // message at a time, per-message trackers only if concurrency appears.
94
+ let currentKey: string | undefined;
95
+ let pendingText: string | undefined;
96
+ let pendingKey: string | undefined;
97
+ let timer: unknown;
98
+ let seq = 0;
99
+ const attemptMisconceptions = new Map<string, string>();
100
+
101
+ function flush(): void {
102
+ timer = undefined;
103
+ if (pendingKey !== undefined && pendingText !== undefined) {
104
+ server.broadcastTutorMessage("assistant", pendingText, pendingKey, false);
105
+ }
106
+ pendingKey = undefined;
107
+ pendingText = undefined;
108
+ }
109
+
110
+ pi.on("message_update", (event) => {
111
+ const text = extractAssistantText(event.message);
112
+ if (text === undefined || text.trim() === "") {
113
+ return;
114
+ }
115
+ let key = messageKey(
116
+ (event.message as { responseId?: unknown }).responseId,
117
+ currentKey
118
+ );
119
+ if (key === "") {
120
+ key = `msg-${++seq}`;
121
+ }
122
+ currentKey = key;
123
+ // 节流窗口内只保留最新文本,flush 时广播一帧。
124
+ pendingKey = key;
125
+ pendingText = text;
126
+ if (timer === undefined) {
127
+ timer = schedule(flush, throttleMs);
128
+ }
129
+ });
130
+
131
+ pi.on("message_end", (event) => {
132
+ const text = extractAssistantText(event.message);
133
+ if (text === undefined || text.trim() === "") {
134
+ // 无可见文本:丢弃未 flush 的流式帧(终帧也不发)。
135
+ if (timer !== undefined) {
136
+ cancel(timer);
137
+ timer = undefined;
138
+ }
139
+ pendingKey = undefined;
140
+ pendingText = undefined;
141
+ currentKey = undefined;
142
+ return;
143
+ }
144
+ const key = messageKey(
145
+ (event.message as { responseId?: unknown }).responseId,
146
+ currentKey
147
+ );
148
+ const messageId = key === "" ? `msg-${++seq}` : key;
149
+ // 终帧携带完整文本:取消未 flush 的节流帧,保证 done:true 最后到达。
150
+ if (timer !== undefined) {
151
+ cancel(timer);
152
+ timer = undefined;
153
+ }
154
+ pendingKey = undefined;
155
+ pendingText = undefined;
156
+ currentKey = undefined;
157
+ server.broadcastTutorMessage("assistant", text, messageId, true);
158
+ });
159
+
160
+ pi.on("tool_execution_start", (event) => {
161
+ if (event.toolName === "learning_record_attempt") {
162
+ // 结束事件没有 args,先保留本次评分提供的误区说明。
163
+ const misconception = event.args?.misconception;
164
+ if (typeof misconception === "string" && misconception.trim() !== "") {
165
+ attemptMisconceptions.set(event.toolCallId, misconception);
166
+ }
167
+ }
168
+ if (event.toolName.startsWith("learning_ask_")) {
169
+ server.broadcastTutorStatus("waiting", event.toolName);
170
+ }
171
+ });
172
+
173
+ pi.on("tool_execution_end", (event) => {
174
+ const misconception = attemptMisconceptions.get(event.toolCallId);
175
+ attemptMisconceptions.delete(event.toolCallId);
176
+ if (event.toolName === "learning_record_attempt" && !event.isError) {
177
+ // 只展示成功结果中的判定,不能将开始评分等同于评分成功。
178
+ const outcome = event.result?.details?.outcome;
179
+ const verdict = outcome === "correct" ? "回答正确"
180
+ : outcome === "partial" ? "回答部分正确"
181
+ : outcome === "incorrect" ? "回答不正确" : undefined;
182
+ if (verdict !== undefined) {
183
+ const explanation = event.result?.details?.explanation;
184
+ const feedback = typeof explanation === "string" && explanation.trim() !== ""
185
+ ? explanation
186
+ : misconception === undefined ? "" : `需要纠正的理解:${misconception}`;
187
+ const text = `**${verdict}**` + (feedback === "" ? "" : `\n\n${feedback}`);
188
+ server.broadcastTutorMessage("assistant", text, `attempt-${event.toolCallId}`, true);
189
+ }
190
+ }
191
+ });
192
+
193
+ pi.on("agent_settled", () => {
194
+ attemptMisconceptions.clear();
195
+ server.broadcastTutorStatus("idle");
196
+ });
197
+ }
@@ -0,0 +1,64 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { LearningStateStore } from "./state/learning-state.js";
4
+
5
+ export interface TutorPromptDependencies {
6
+ state: LearningStateStore;
7
+ }
8
+
9
+ export function registerTutorPrompt(
10
+ pi: ExtensionAPI,
11
+ dependencies: TutorPromptDependencies
12
+ ): void {
13
+ pi.on("before_agent_start", (event) => {
14
+ const state = dependencies.state.snapshot();
15
+ if (!state.enabled) {
16
+ return;
17
+ }
18
+
19
+ const conceptState = Object.values(state.concepts).map((concept) => {
20
+ const misconceptions = concept.misconceptions
21
+ .slice(0, 3)
22
+ .map(toPromptLine)
23
+ .join(" | ");
24
+ return (
25
+ `- ${toPromptLine(concept.id)}: mastery=${concept.mastery.toFixed(2)}, ` +
26
+ `attempts=${concept.attempts}, correct=${concept.correct}, ` +
27
+ `misconceptions=${misconceptions === "" ? "none" : misconceptions}`
28
+ );
29
+ });
30
+
31
+ const learningPrompt = [
32
+ "You are operating in Learning Mode.",
33
+ "",
34
+ "Teaching rules:",
35
+ "1. Teach interactively rather than dumping a full solution.",
36
+ "2. Use one primary learner interaction at a time.",
37
+ "3. Use learning_ask_single_choice for single-choice checks; do not render fake choices in Markdown.",
38
+ "4. Use learning_ask_multi_choice when multiple answers are correct; do not render fake checkboxes in Markdown.",
39
+ "5. Use learning_ask_free_response for open learner responses.",
40
+ "6. Use learning_ask_code for coding exercises; do not ask the learner to paste code into ordinary chat.",
41
+ "7. After an answer, evaluate it, explain the key point, and decide the next teaching action.",
42
+ "8. Prefer retrieval practice and application over repeated explanation.",
43
+ "9. Adapt difficulty to the learner state and do not infer mastery from one lucky choice.",
44
+ "10. Keep explanations concise enough to preserve active participation.",
45
+ "11. When a registered learning interaction tool fits, use it instead of ordinary chat input.",
46
+ "12. Make every learning_ask_* prompt self-contained: put all code, examples, and context inside the question/instructions parameter itself. The learner's active panel shows only that parameter's content, never your previous chat messages — a question without its code is unanswerable.",
47
+ "13. After evaluating each answer, call learning_record_attempt with that interaction's interactionId, the conceptId, outcome (correct/partial/incorrect), evidenceType matching the interaction (choice for single/multi choice, free_response for open answers, code for code exercises), and a required explanation of the correct answer and key reasoning in the learner's language. The workspace displays this explanation with the outcome; complete this feedback before asking the next question. Include misconception when the answer was wrong. Do not record attempts whose tool result says skipped: true — a skipped question is not an answer.",
48
+ "",
49
+ `Current course: ${state.course?.title ?? "unspecified"}`,
50
+ `Current topic: ${state.topic?.title ?? "unspecified"}`,
51
+ `Current phase: ${state.phase}`,
52
+ `Tracked concepts: ${Object.keys(state.concepts).length}`,
53
+ `Recent attempts: ${state.recentAttempts.length}`,
54
+ "Concept state:",
55
+ ...(conceptState.length === 0 ? ["- none"] : conceptState)
56
+ ].join("\n");
57
+
58
+ return { systemPrompt: `${event.systemPrompt}\n\n${learningPrompt}` };
59
+ });
60
+ }
61
+
62
+ function toPromptLine(value: string): string {
63
+ return value.replace(/\s+/gu, " ").trim().slice(0, 200);
64
+ }
@@ -0,0 +1,35 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Open a URL in the system browser (spec 19). Never rejects: the caller has
5
+ * already shown the URL, so a missing opener must not break the command.
6
+ * Uses spawn (no shell string interpolation) to avoid quoting issues.
7
+ *
8
+ * Headless/CI (spec 19): set PI_LEARNING_NO_BROWSER=1 to skip the opener
9
+ * entirely. The workspace URL is still printed by /learn, so an automated or
10
+ * remote run loses nothing.
11
+ */
12
+ export function openWorkspace(url: string): Promise<void> {
13
+ if (process.env.PI_LEARNING_NO_BROWSER === "1") {
14
+ return Promise.resolve();
15
+ }
16
+
17
+ let command: string;
18
+ let args: string[];
19
+ if (process.platform === "win32") {
20
+ command = "cmd.exe";
21
+ args = ["/c", "start", "", url];
22
+ } else if (process.platform === "darwin") {
23
+ command = "open";
24
+ args = [url];
25
+ } else {
26
+ command = "xdg-open";
27
+ args = [url];
28
+ }
29
+
30
+ return new Promise((resolve) => {
31
+ const child = spawn(command, args, { stdio: "ignore", windowsHide: true });
32
+ child.once("error", () => resolve());
33
+ child.once("exit", () => resolve());
34
+ });
35
+ }
@@ -0,0 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export function createInteractionId(prefix: string): string {
4
+ return `${prefix}_${randomUUID()}`;
5
+ }
@@ -0,0 +1,138 @@
1
+ import type {
2
+ CodeExerciseAnswer,
3
+ FreeResponseAnswer,
4
+ LearningInteraction,
5
+ MultiChoiceAnswer,
6
+ SingleChoiceAnswer
7
+ } from "../../shared/protocol.js";
8
+
9
+ export type AnswerValidationResult =
10
+ | { ok: true; type: "single_choice"; answer: SingleChoiceAnswer }
11
+ | { ok: true; type: "multi_choice"; answer: MultiChoiceAnswer }
12
+ | { ok: true; type: "free_response"; answer: FreeResponseAnswer }
13
+ | { ok: true; type: "code"; answer: CodeExerciseAnswer }
14
+ | { ok: false; message: string };
15
+
16
+ export function validateInteractionAnswer(
17
+ interaction: LearningInteraction,
18
+ answer: unknown
19
+ ): AnswerValidationResult {
20
+ if (interaction.type === "code") {
21
+ if (
22
+ typeof answer !== "object" ||
23
+ answer === null ||
24
+ !("language" in answer) ||
25
+ typeof answer.language !== "string" ||
26
+ !("code" in answer) ||
27
+ typeof answer.code !== "string"
28
+ ) {
29
+ return {
30
+ ok: false,
31
+ message: `Interaction ${interaction.id} requires language and code strings.`
32
+ };
33
+ }
34
+ if (answer.language !== interaction.language) {
35
+ return {
36
+ ok: false,
37
+ message: `Language ${answer.language} does not match interaction ${interaction.id}.`
38
+ };
39
+ }
40
+
41
+ return {
42
+ ok: true,
43
+ type: interaction.type,
44
+ answer: { language: answer.language, code: answer.code }
45
+ };
46
+ }
47
+
48
+ if (interaction.type === "free_response") {
49
+ if (
50
+ typeof answer !== "object" ||
51
+ answer === null ||
52
+ !("text" in answer) ||
53
+ typeof answer.text !== "string"
54
+ ) {
55
+ return {
56
+ ok: false,
57
+ message: `Interaction ${interaction.id} requires a text string.`
58
+ };
59
+ }
60
+
61
+ return { ok: true, type: interaction.type, answer: { text: answer.text } };
62
+ }
63
+
64
+ if (interaction.type === "multi_choice") {
65
+ if (
66
+ typeof answer !== "object" ||
67
+ answer === null ||
68
+ !("optionIds" in answer) ||
69
+ !Array.isArray(answer.optionIds)
70
+ ) {
71
+ return {
72
+ ok: false,
73
+ message: `Interaction ${interaction.id} requires an optionIds array.`
74
+ };
75
+ }
76
+
77
+ if (answer.optionIds.length === 0) {
78
+ return {
79
+ ok: false,
80
+ message: `Interaction ${interaction.id} requires at least one selected option.`
81
+ };
82
+ }
83
+
84
+ if (!answer.optionIds.every((optionId) => typeof optionId === "string")) {
85
+ return {
86
+ ok: false,
87
+ message: `Interaction ${interaction.id} requires optionIds to be strings.`
88
+ };
89
+ }
90
+
91
+ const validIds = new Set(interaction.options.map((option) => option.id));
92
+ const unknown = answer.optionIds.find((optionId) => !validIds.has(optionId));
93
+ if (unknown !== undefined) {
94
+ return {
95
+ ok: false,
96
+ message: `Option ${unknown} does not belong to interaction ${interaction.id}.`
97
+ };
98
+ }
99
+
100
+ if (new Set(answer.optionIds).size !== answer.optionIds.length) {
101
+ return {
102
+ ok: false,
103
+ message: `Interaction ${interaction.id} contains duplicate options.`
104
+ };
105
+ }
106
+
107
+ return {
108
+ ok: true,
109
+ type: interaction.type,
110
+ answer: { optionIds: answer.optionIds }
111
+ };
112
+ }
113
+
114
+ if (
115
+ typeof answer !== "object" ||
116
+ answer === null ||
117
+ !("optionId" in answer) ||
118
+ typeof answer.optionId !== "string"
119
+ ) {
120
+ return {
121
+ ok: false,
122
+ message: `Interaction ${interaction.id} requires an optionId string.`
123
+ };
124
+ }
125
+
126
+ if (!interaction.options.some((option) => option.id === answer.optionId)) {
127
+ return {
128
+ ok: false,
129
+ message: `Option ${answer.optionId} does not belong to interaction ${interaction.id}.`
130
+ };
131
+ }
132
+
133
+ return {
134
+ ok: true,
135
+ type: interaction.type,
136
+ answer: { optionId: answer.optionId }
137
+ };
138
+ }
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "pi-learning-agent",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Structured learning interactions for Pi Coding Agent",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "learning",
11
+ "tutor"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/marskim1130/pi-learning.git"
16
+ },
17
+ "homepage": "https://github.com/marskim1130/pi-learning#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/marskim1130/pi-learning/issues"
20
+ },
21
+ "files": [
22
+ "extension/",
23
+ "shared/",
24
+ "web/dist/",
25
+ "README.md",
26
+ "LICENSE",
27
+ "CONTRIBUTING.md",
28
+ "SECURITY.md",
29
+ "CHANGELOG.md",
30
+ "docs/architecture.md",
31
+ "docs/images/learning-workspace.png",
32
+ "docs/releasing.md"
33
+ ],
34
+ "workspaces": [
35
+ "web"
36
+ ],
37
+ "pi": {
38
+ "extensions": [
39
+ "./extension/index.ts"
40
+ ]
41
+ },
42
+ "engines": {
43
+ "node": ">=22.19.0"
44
+ },
45
+ "scripts": {
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
48
+ "typecheck": "tsc --noEmit",
49
+ "typecheck:web": "npm --prefix web run typecheck",
50
+ "build:web": "npm --prefix web run build",
51
+ "check": "npm run typecheck && npm run typecheck:web && npm test",
52
+ "e2e": "node tests/e2e-rpc.mjs",
53
+ "e2e:browser": "playwright test",
54
+ "test:workspace": "playwright test --config playwright.workspace.config.ts",
55
+ "pi": "pi -e ./extension/index.ts",
56
+ "check:package": "npm run build:web && vitest run --config vitest.release.config.ts",
57
+ "smoke": "node tests/rpc-smoke.mjs",
58
+ "check:release": "npm run check && npm run check:package && npm run smoke",
59
+ "release:prepare": "npm run check:release && npm pack --ignore-scripts",
60
+ "prepack": "npm run build:web"
61
+ },
62
+ "peerDependencies": {
63
+ "@earendil-works/pi-ai": "*",
64
+ "@earendil-works/pi-coding-agent": "*",
65
+ "typebox": "*"
66
+ },
67
+ "overrides": {
68
+ "monaco-editor@0.56.0": {
69
+ "dompurify": "3.4.13"
70
+ }
71
+ },
72
+ "devDependencies": {
73
+ "@earendil-works/pi-ai": "0.84.0",
74
+ "@earendil-works/pi-coding-agent": "0.84.0",
75
+ "@playwright/test": "^1.62.1",
76
+ "@types/node": "24.12.4",
77
+ "jsdom": "^30.0.1",
78
+ "typebox": "1.3.7",
79
+ "typescript": "5.9.3",
80
+ "vitest": "4.1.9"
81
+ }
82
+ }
@@ -0,0 +1,62 @@
1
+ export type LearningPhase =
2
+ | "idle"
3
+ | "diagnosing"
4
+ | "explaining"
5
+ | "checking"
6
+ | "practicing"
7
+ | "reviewing";
8
+
9
+ export interface LearningCourse {
10
+ id: string;
11
+ title: string;
12
+ }
13
+
14
+ export interface LearningTopic {
15
+ id: string;
16
+ title: string;
17
+ }
18
+
19
+ export type AttemptOutcome = "correct" | "partial" | "incorrect";
20
+
21
+ export type EvidenceType = "choice" | "free_response" | "code";
22
+
23
+ /** One recorded answer, newest first; drives the 0.75 ceiling rule (spec 16.1). */
24
+ export interface RecentOutcome {
25
+ outcome: AttemptOutcome;
26
+ evidenceType: EvidenceType;
27
+ }
28
+
29
+ export interface ConceptState {
30
+ id: string;
31
+ title: string;
32
+ mastery: number;
33
+ attempts: number;
34
+ correct: number;
35
+ lastPracticedAt?: number;
36
+ misconceptions: string[];
37
+ /**
38
+ * Optional: absent in snapshots written before this field existed, so
39
+ * restore() must keep accepting concepts without it (backward compatible).
40
+ */
41
+ recentOutcomes?: RecentOutcome[];
42
+ }
43
+
44
+ export interface AttemptSummary {
45
+ interactionId: string;
46
+ conceptId: string;
47
+ outcome: AttemptOutcome;
48
+ evidenceType: EvidenceType;
49
+ misconception?: string;
50
+ recordedAt: number;
51
+ }
52
+
53
+ export interface LearningState {
54
+ enabled: boolean;
55
+ course?: LearningCourse;
56
+ topic?: LearningTopic;
57
+ phase: LearningPhase;
58
+ concepts: Record<string, ConceptState>;
59
+ recentAttempts: AttemptSummary[];
60
+ /** Bounded idempotency ledger; optional for snapshots written before v0.1. */
61
+ recordedInteractionIds?: string[];
62
+ }