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,63 @@
1
+ import type { ServerResponse } from "node:http";
2
+
3
+ import type { LearningEvent } from "../../shared/protocol.js";
4
+
5
+ const HEARTBEAT_INTERVAL_MS = 20_000;
6
+
7
+ /**
8
+ * Fan-out for server-sent events (spec 9.3). Writes `event: <type>\ndata: <json>\n\n`
9
+ * frames to every connected response and emits a `: ping` comment every 20s so
10
+ * proxies/browsers do not silently drop idle connections.
11
+ */
12
+ export class SseHub {
13
+ private readonly clients = new Set<ServerResponse>();
14
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
15
+
16
+ get size(): number {
17
+ return this.clients.size;
18
+ }
19
+
20
+ addClient(response: ServerResponse): void {
21
+ response.on("close", () => this.removeClient(response));
22
+ response.on("error", () => this.removeClient(response));
23
+ this.clients.add(response);
24
+ if (this.heartbeatTimer === undefined) {
25
+ this.heartbeatTimer = setInterval(
26
+ () => this.heartbeat(),
27
+ HEARTBEAT_INTERVAL_MS
28
+ );
29
+ }
30
+ }
31
+
32
+ removeClient(response: ServerResponse): void {
33
+ this.clients.delete(response);
34
+ if (this.clients.size === 0 && this.heartbeatTimer !== undefined) {
35
+ clearInterval(this.heartbeatTimer);
36
+ this.heartbeatTimer = undefined;
37
+ }
38
+ }
39
+
40
+ broadcast(event: LearningEvent): void {
41
+ const frame = `event: ${event.event}\ndata: ${JSON.stringify(event)}\n\n`;
42
+ for (const client of this.clients) {
43
+ client.write(frame);
44
+ }
45
+ }
46
+
47
+ close(): void {
48
+ if (this.heartbeatTimer !== undefined) {
49
+ clearInterval(this.heartbeatTimer);
50
+ this.heartbeatTimer = undefined;
51
+ }
52
+ for (const client of [...this.clients]) {
53
+ client.end();
54
+ }
55
+ this.clients.clear();
56
+ }
57
+
58
+ private heartbeat(): void {
59
+ for (const client of this.clients) {
60
+ client.write(": ping\n\n");
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,458 @@
1
+ import type {
2
+ AttemptOutcome,
3
+ AttemptSummary,
4
+ ConceptState,
5
+ EvidenceType,
6
+ LearningCourse,
7
+ LearningState,
8
+ LearningTopic,
9
+ LearningPhase,
10
+ RecentOutcome
11
+ } from "../../shared/learning-state.js";
12
+
13
+ export interface StartLearningInput {
14
+ course: LearningCourse;
15
+ topic: LearningTopic;
16
+ }
17
+
18
+ /**
19
+ * Application-layer phase transition events (spec 15). The model decides the
20
+ * teaching, the app only records the current phase label: transitions fire
21
+ * from /learn, question presentation (broker.onPresented) and recorded
22
+ * attempts.
23
+ */
24
+ export type PhaseEvent =
25
+ | { type: "start" }
26
+ | { type: "stop" }
27
+ | { type: "question_presented" }
28
+ | { type: "attempt_recorded"; outcome: AttemptOutcome };
29
+
30
+ /**
31
+ * Pure phase transition table (spec 15). Deliberately coarse:
32
+ * - start → diagnosing
33
+ * - stop → idle
34
+ * - question_presented: explaining → checking; other phases keep their label
35
+ * (diagnosing questions stay diagnosing, application stays practicing)
36
+ * - attempt_recorded: correct → practicing, incorrect → diagnosing
37
+ * (misconception loop), partial keeps the current phase
38
+ * reviewing is not auto-reached by any event yet (no /learn-review trigger);
39
+ * it survives only via state restore/persistence.
40
+ */
41
+ export function nextPhase(phase: LearningPhase, event: PhaseEvent): LearningPhase {
42
+ switch (event.type) {
43
+ case "start":
44
+ return "diagnosing";
45
+ case "stop":
46
+ return "idle";
47
+ case "question_presented":
48
+ return phase === "explaining" ? "checking" : phase;
49
+ case "attempt_recorded":
50
+ if (event.outcome === "correct") {
51
+ return "practicing";
52
+ }
53
+ return event.outcome === "incorrect" ? "diagnosing" : phase;
54
+ }
55
+ }
56
+
57
+ export interface RecordAttemptInput {
58
+ interactionId: string;
59
+ conceptId: string;
60
+ outcome: AttemptOutcome;
61
+ evidenceType: EvidenceType;
62
+ misconception?: string;
63
+ }
64
+
65
+ export interface LearningStateStoreOptions {
66
+ /** Invoked after every state mutation (start/stop/recordAttempt) with the post-change snapshot. */
67
+ onChange?: (snapshot: LearningState) => void;
68
+ }
69
+
70
+ /** Initial mastery for a newly created concept (spec 16.1). */
71
+ export const INITIAL_MASTERY = 0.2;
72
+
73
+ /** Mastery gains per correct answer, by evidence form (spec 16.1). */
74
+ const MASTERY_GAINS: Record<EvidenceType, number> = {
75
+ choice: 0.08,
76
+ free_response: 0.12,
77
+ code: 0.15
78
+ };
79
+
80
+ /**
81
+ * Mastery penalties (spec 16.1 defines only incorrect -0.08; partial is not
82
+ * defined there, so it gets the same -0.04 penalty used for "mostly right").
83
+ */
84
+ const MASTERY_PENALTIES: Record<Exclude<AttemptOutcome, "correct">, number> = {
85
+ incorrect: 0.08,
86
+ partial: 0.04
87
+ };
88
+
89
+ /**
90
+ * Ceiling rule (spec 16.1): mastery may exceed 0.75 only after the most
91
+ * recent consecutive correct records include at least 2 distinct evidence
92
+ * forms — a single question type alone cannot reach "mastered".
93
+ */
94
+ const MASTERY_CEILING = 0.75;
95
+ const MAX_RECENT_ATTEMPTS = 20;
96
+ const MAX_RECORDED_INTERACTION_IDS = 1_000;
97
+ const MAX_RECENT_OUTCOMES = 10;
98
+ const MAX_MISCONCEPTIONS = 10;
99
+
100
+ /**
101
+ * Pure mastery update (spec 16.1). `concept.recentOutcomes` is the recent
102
+ * history INCLUDING the attempt being recorded (newest first); callers must
103
+ * prepend the new outcome before calling.
104
+ */
105
+ export function updateMastery(
106
+ concept: ConceptState,
107
+ attempt: { outcome: AttemptOutcome; evidenceType: EvidenceType }
108
+ ): number {
109
+ const delta =
110
+ attempt.outcome === "correct"
111
+ ? MASTERY_GAINS[attempt.evidenceType]
112
+ : -MASTERY_PENALTIES[attempt.outcome];
113
+ let mastery = round2(concept.mastery + delta);
114
+
115
+ if (attempt.outcome === "correct" && mastery > MASTERY_CEILING) {
116
+ if (!hasDiverseRecentCorrect(concept.recentOutcomes)) {
117
+ mastery = MASTERY_CEILING;
118
+ }
119
+ }
120
+
121
+ return clamp01(mastery);
122
+ }
123
+
124
+ export class LearningStateStore {
125
+ private readonly onChange: ((snapshot: LearningState) => void) | undefined;
126
+ private state: LearningState = initialLearningState();
127
+
128
+ constructor(options: LearningStateStoreOptions = {}) {
129
+ this.onChange = options.onChange;
130
+ }
131
+
132
+ snapshot(): LearningState {
133
+ return structuredClone(this.state);
134
+ }
135
+
136
+ /** Clear process-local state before replaying a different Pi session branch. */
137
+ resetForSession(): void {
138
+ this.state = initialLearningState();
139
+ }
140
+
141
+ restore(candidate: unknown): boolean {
142
+ if (!isLearningState(candidate)) {
143
+ return false;
144
+ }
145
+
146
+ this.state = structuredClone(candidate);
147
+ return true;
148
+ }
149
+
150
+ start(input: StartLearningInput): void {
151
+ this.state = {
152
+ ...this.state,
153
+ enabled: true,
154
+ course: { ...input.course },
155
+ topic: { ...input.topic },
156
+ phase: nextPhase(this.state.phase, { type: "start" })
157
+ };
158
+ this.notifyChange();
159
+ }
160
+
161
+ stop(): void {
162
+ this.state = {
163
+ ...this.state,
164
+ enabled: false,
165
+ phase: nextPhase(this.state.phase, { type: "stop" })
166
+ };
167
+ this.notifyChange();
168
+ }
169
+
170
+ /**
171
+ * Spec 18 /learn-reset: clear the current topic's learner state (concepts,
172
+ * mastery, attempt history, idempotency ledger) but keep course/topic and
173
+ * learning mode; a fresh session starts diagnosing again.
174
+ */
175
+ resetTopic(): void {
176
+ this.state = {
177
+ ...this.state,
178
+ phase: nextPhase(this.state.phase, { type: "start" }),
179
+ concepts: {},
180
+ recentAttempts: [],
181
+ recordedInteractionIds: []
182
+ };
183
+ this.notifyChange();
184
+ }
185
+
186
+ /**
187
+ * Spec 15: a learning_ask_* tool presented a question. Only migrates
188
+ * explaining → checking; other phases keep their label. No-op when learning
189
+ * mode is off.
190
+ */
191
+ questionPresented(): void {
192
+ if (!this.state.enabled) {
193
+ return;
194
+ }
195
+ const phase = nextPhase(this.state.phase, { type: "question_presented" });
196
+ if (phase !== this.state.phase) {
197
+ this.state.phase = phase;
198
+ this.notifyChange();
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Record a tutor-evaluated attempt (spec 30): creates the concept at
204
+ * INITIAL_MASTERY when unknown, updates mastery via updateMastery, and
205
+ * prepends to recentAttempts/recentOutcomes (bounded).
206
+ */
207
+ recordAttempt(input: RecordAttemptInput): AttemptSummary {
208
+ const recordedInteractionIds =
209
+ this.state.recordedInteractionIds ??
210
+ [...new Set(this.state.recentAttempts.map((attempt) => attempt.interactionId))];
211
+ if (recordedInteractionIds.includes(input.interactionId)) {
212
+ throw new Error(
213
+ `Attempt for interaction ${input.interactionId} has already been recorded.`
214
+ );
215
+ }
216
+
217
+ const recordedAt = Date.now();
218
+ const existing = Object.hasOwn(this.state.concepts, input.conceptId)
219
+ ? this.state.concepts[input.conceptId]
220
+ : undefined;
221
+ const concept: ConceptState =
222
+ existing === undefined
223
+ ? {
224
+ id: input.conceptId,
225
+ title: input.conceptId,
226
+ mastery: INITIAL_MASTERY,
227
+ attempts: 0,
228
+ correct: 0,
229
+ misconceptions: []
230
+ }
231
+ : { ...existing, misconceptions: [...existing.misconceptions] };
232
+
233
+ concept.attempts += 1;
234
+ if (input.outcome === "correct") {
235
+ concept.correct += 1;
236
+ }
237
+ if (
238
+ input.outcome === "incorrect" &&
239
+ input.misconception !== undefined &&
240
+ !concept.misconceptions.includes(input.misconception)
241
+ ) {
242
+ concept.misconceptions = [
243
+ input.misconception,
244
+ ...concept.misconceptions
245
+ ].slice(0, MAX_MISCONCEPTIONS);
246
+ }
247
+ concept.recentOutcomes = (
248
+ [
249
+ { outcome: input.outcome, evidenceType: input.evidenceType },
250
+ ...(concept.recentOutcomes ?? [])
251
+ ] as RecentOutcome[]
252
+ ).slice(0, MAX_RECENT_OUTCOMES);
253
+ concept.mastery = updateMastery(concept, input);
254
+ concept.lastPracticedAt = recordedAt;
255
+
256
+ this.state.concepts = { ...this.state.concepts, [input.conceptId]: concept };
257
+
258
+ const summary: AttemptSummary = {
259
+ interactionId: input.interactionId,
260
+ conceptId: input.conceptId,
261
+ outcome: input.outcome,
262
+ evidenceType: input.evidenceType,
263
+ ...(input.misconception === undefined
264
+ ? {}
265
+ : { misconception: input.misconception }),
266
+ recordedAt
267
+ };
268
+ this.state.recentAttempts = [summary, ...this.state.recentAttempts].slice(
269
+ 0,
270
+ MAX_RECENT_ATTEMPTS
271
+ );
272
+ this.state.recordedInteractionIds = [
273
+ input.interactionId,
274
+ ...recordedInteractionIds
275
+ ].slice(0, MAX_RECORDED_INTERACTION_IDS);
276
+
277
+ // Spec 15: correct answer moves to application (practicing), incorrect
278
+ // moves back to misconception diagnosis. Only while learning mode is on.
279
+ if (this.state.enabled) {
280
+ this.state.phase = nextPhase(this.state.phase, {
281
+ type: "attempt_recorded",
282
+ outcome: input.outcome
283
+ });
284
+ }
285
+
286
+ this.notifyChange();
287
+ return summary;
288
+ }
289
+
290
+ private notifyChange(): void {
291
+ this.onChange?.(this.snapshot());
292
+ }
293
+ }
294
+
295
+ function initialLearningState(): LearningState {
296
+ return {
297
+ enabled: false,
298
+ phase: "idle",
299
+ concepts: {},
300
+ recentAttempts: []
301
+ };
302
+ }
303
+
304
+ /** True when the trailing run of correct records contains >= 2 evidence forms. */
305
+ function hasDiverseRecentCorrect(
306
+ recentOutcomes: readonly RecentOutcome[] | undefined
307
+ ): boolean {
308
+ const seen = new Set<EvidenceType>();
309
+ for (const entry of recentOutcomes ?? []) {
310
+ if (entry.outcome !== "correct") {
311
+ break;
312
+ }
313
+ seen.add(entry.evidenceType);
314
+ if (seen.size >= 2) {
315
+ return true;
316
+ }
317
+ }
318
+ return false;
319
+ }
320
+
321
+ function round2(value: number): number {
322
+ return Math.round(value * 100) / 100;
323
+ }
324
+
325
+ function clamp01(value: number): number {
326
+ return Math.min(1, Math.max(0, value));
327
+ }
328
+
329
+ const phases: readonly LearningPhase[] = [
330
+ "idle",
331
+ "diagnosing",
332
+ "explaining",
333
+ "checking",
334
+ "practicing",
335
+ "reviewing"
336
+ ];
337
+
338
+ function isLearningState(value: unknown): value is LearningState {
339
+ if (!isRecord(value)) {
340
+ return false;
341
+ }
342
+
343
+ return (
344
+ typeof value.enabled === "boolean" &&
345
+ typeof value.phase === "string" &&
346
+ phases.includes(value.phase as LearningPhase) &&
347
+ isOptionalCourseOrTopic(value.course) &&
348
+ isOptionalCourseOrTopic(value.topic) &&
349
+ isConceptMap(value.concepts) &&
350
+ Array.isArray(value.recentAttempts) &&
351
+ value.recentAttempts.every(isAttemptSummary) &&
352
+ (value.recordedInteractionIds === undefined ||
353
+ (Array.isArray(value.recordedInteractionIds) &&
354
+ value.recordedInteractionIds.length <= MAX_RECORDED_INTERACTION_IDS &&
355
+ value.recordedInteractionIds.every(isNonEmptyString) &&
356
+ new Set(value.recordedInteractionIds).size ===
357
+ value.recordedInteractionIds.length))
358
+ );
359
+ }
360
+
361
+ function isOptionalCourseOrTopic(value: unknown): boolean {
362
+ return value === undefined || isCourseOrTopic(value);
363
+ }
364
+
365
+ function isCourseOrTopic(value: unknown): boolean {
366
+ return (
367
+ isRecord(value) &&
368
+ isNonEmptyString(value.id) &&
369
+ isNonEmptyString(value.title)
370
+ );
371
+ }
372
+
373
+ function isConceptMap(value: unknown): boolean {
374
+ if (!isRecord(value)) {
375
+ return false;
376
+ }
377
+
378
+ return Object.values(value).every(isConceptState);
379
+ }
380
+
381
+ function isConceptState(value: unknown): value is ConceptState {
382
+ if (!isRecord(value)) {
383
+ return false;
384
+ }
385
+
386
+ return (
387
+ isNonEmptyString(value.id) &&
388
+ isNonEmptyString(value.title) &&
389
+ isBoundedNumber(value.mastery, 0, 1) &&
390
+ isNonNegativeInteger(value.attempts) &&
391
+ isNonNegativeInteger(value.correct) &&
392
+ (value.lastPracticedAt === undefined ||
393
+ (typeof value.lastPracticedAt === "number" &&
394
+ Number.isFinite(value.lastPracticedAt))) &&
395
+ Array.isArray(value.misconceptions) &&
396
+ value.misconceptions.every((item) => typeof item === "string") &&
397
+ (value.recentOutcomes === undefined ||
398
+ (Array.isArray(value.recentOutcomes) &&
399
+ value.recentOutcomes.every(isRecentOutcome)))
400
+ );
401
+ }
402
+
403
+ function isRecentOutcome(value: unknown): value is RecentOutcome {
404
+ if (!isRecord(value)) {
405
+ return false;
406
+ }
407
+
408
+ return (
409
+ (value.outcome === "correct" ||
410
+ value.outcome === "partial" ||
411
+ value.outcome === "incorrect") &&
412
+ (value.evidenceType === "choice" ||
413
+ value.evidenceType === "free_response" ||
414
+ value.evidenceType === "code")
415
+ );
416
+ }
417
+
418
+ function isAttemptSummary(value: unknown): value is AttemptSummary {
419
+ if (!isRecord(value)) {
420
+ return false;
421
+ }
422
+
423
+ return (
424
+ isNonEmptyString(value.interactionId) &&
425
+ isNonEmptyString(value.conceptId) &&
426
+ (value.outcome === "correct" ||
427
+ value.outcome === "partial" ||
428
+ value.outcome === "incorrect") &&
429
+ (value.evidenceType === "choice" ||
430
+ value.evidenceType === "free_response" ||
431
+ value.evidenceType === "code") &&
432
+ (value.misconception === undefined ||
433
+ typeof value.misconception === "string") &&
434
+ typeof value.recordedAt === "number" &&
435
+ Number.isFinite(value.recordedAt)
436
+ );
437
+ }
438
+
439
+ function isRecord(value: unknown): value is Record<string, unknown> {
440
+ return typeof value === "object" && value !== null && !Array.isArray(value);
441
+ }
442
+
443
+ function isNonEmptyString(value: unknown): value is string {
444
+ return typeof value === "string" && value.trim().length > 0;
445
+ }
446
+
447
+ function isBoundedNumber(value: unknown, min: number, max: number): value is number {
448
+ return (
449
+ typeof value === "number" &&
450
+ Number.isFinite(value) &&
451
+ value >= min &&
452
+ value <= max
453
+ );
454
+ }
455
+
456
+ function isNonNegativeInteger(value: unknown): value is number {
457
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
458
+ }
@@ -0,0 +1,72 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ SessionEntry
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { LearningState } from "../../shared/learning-state.js";
8
+ import type { LearningStateStore } from "./learning-state.js";
9
+ import type { LearningSession } from "../learning-session.js";
10
+
11
+ export const LEARNING_STATE_ENTRY_TYPE = "learning-state";
12
+ export const LEARNING_STATE_VERSION = 1;
13
+
14
+ export interface LearningStateEntryData {
15
+ version: typeof LEARNING_STATE_VERSION;
16
+ state: LearningState;
17
+ }
18
+
19
+ export function createLearningStateEntryData(
20
+ state: LearningStateStore
21
+ ): LearningStateEntryData {
22
+ return {
23
+ version: LEARNING_STATE_VERSION,
24
+ state: state.snapshot()
25
+ };
26
+ }
27
+
28
+ export function restoreLearningStateFromEntries(
29
+ state: LearningStateStore,
30
+ entries: readonly SessionEntry[]
31
+ ): boolean {
32
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
33
+ const entry = entries[index];
34
+ if (
35
+ entry?.type !== "custom" ||
36
+ entry.customType !== LEARNING_STATE_ENTRY_TYPE ||
37
+ !isVersionOneEntry(entry.data)
38
+ ) {
39
+ continue;
40
+ }
41
+
42
+ if (state.restore(entry.data.state)) {
43
+ return true;
44
+ }
45
+ }
46
+
47
+ return false;
48
+ }
49
+
50
+ export function registerLearningStatePersistence(
51
+ pi: ExtensionAPI,
52
+ session: LearningSession,
53
+ onRestored: () => void = () => undefined
54
+ ): void {
55
+ const restore = (_event: unknown, ctx: ExtensionContext): void => {
56
+ session.resetForSession();
57
+ restoreLearningStateFromEntries(session.state, ctx.sessionManager.getBranch());
58
+ onRestored();
59
+ };
60
+ pi.on("session_start", restore);
61
+ pi.on("session_tree", restore);
62
+ }
63
+
64
+ function isVersionOneEntry(value: unknown): value is LearningStateEntryData {
65
+ return (
66
+ typeof value === "object" &&
67
+ value !== null &&
68
+ "version" in value &&
69
+ value.version === LEARNING_STATE_VERSION &&
70
+ "state" in value
71
+ );
72
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ defineTool,
3
+ type ExtensionContext
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Type } from "typebox";
6
+
7
+ import type {
8
+ CodeExerciseInteraction,
9
+ CodeExerciseResolvedAnswer
10
+ } from "../../shared/protocol.js";
11
+ import { createInteractionId } from "../utils/ids.js";
12
+ import { presentCodeInTui } from "./tui-presenter.js";
13
+
14
+ const parameters = Type.Object({
15
+ title: Type.Optional(Type.String({ minLength: 1 })),
16
+ instructions: Type.String({ minLength: 1 }),
17
+ language: Type.String({ minLength: 1 }),
18
+ starterCode: Type.Optional(Type.String()),
19
+ readOnlyRanges: Type.Optional(
20
+ Type.Array(
21
+ Type.Object({
22
+ start: Type.Integer({ minimum: 0 }),
23
+ end: Type.Integer({ minimum: 1 })
24
+ })
25
+ )
26
+ ),
27
+ conceptId: Type.Optional(Type.String({ minLength: 1 }))
28
+ });
29
+
30
+ export interface CodeToolDetails extends CodeExerciseResolvedAnswer {
31
+ conceptId?: string;
32
+ }
33
+
34
+ export type CodePresenter = (
35
+ interaction: CodeExerciseInteraction,
36
+ signal: AbortSignal | undefined,
37
+ ctx: ExtensionContext
38
+ ) => Promise<CodeExerciseResolvedAnswer>;
39
+
40
+ export interface CodeToolDependencies {
41
+ createId?: () => string;
42
+ now?: () => number;
43
+ present?: CodePresenter;
44
+ }
45
+
46
+ export function createAskCodeTool(dependencies: CodeToolDependencies = {}) {
47
+ const createId = dependencies.createId ?? (() => createInteractionId("code"));
48
+ const now = dependencies.now ?? Date.now;
49
+ const present =
50
+ dependencies.present ??
51
+ ((interaction, signal, ctx) =>
52
+ presentCodeInTui(interaction, signal, ctx, now));
53
+
54
+ return defineTool<typeof parameters, CodeToolDetails>({
55
+ name: "learning_ask_code",
56
+ label: "Learning: Code Exercise",
57
+ description:
58
+ "Ask the learner to write code and wait for a structured code submission. This tool does not execute code.",
59
+ promptSnippet: "Ask a structured coding exercise",
60
+ executionMode: "sequential",
61
+ parameters,
62
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
63
+ const interaction: CodeExerciseInteraction = {
64
+ id: createId(),
65
+ type: "code",
66
+ instructions: params.instructions,
67
+ language: params.language,
68
+ starterCode: params.starterCode ?? "",
69
+ ...(params.readOnlyRanges === undefined
70
+ ? {}
71
+ : { readOnlyRanges: params.readOnlyRanges }),
72
+ createdAt: now(),
73
+ ...(params.title === undefined ? {} : { title: params.title }),
74
+ ...(params.conceptId === undefined
75
+ ? {}
76
+ : { conceptId: params.conceptId })
77
+ };
78
+ const resolved = await present(interaction, signal, ctx);
79
+ const details: CodeToolDetails = {
80
+ ...resolved,
81
+ ...(interaction.conceptId === undefined
82
+ ? {}
83
+ : { conceptId: interaction.conceptId })
84
+ };
85
+
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text",
90
+ text: `Learner submitted code in ${resolved.answer.language}:\n${resolved.answer.code}\n(interaction ${resolved.interactionId})`
91
+ }
92
+ ],
93
+ details
94
+ };
95
+ }
96
+ });
97
+ }