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.
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +9 -0
- package/docs/architecture.md +75 -0
- package/docs/images/learning-workspace.png +0 -0
- package/docs/releasing.md +51 -0
- package/extension/commands.ts +181 -0
- package/extension/index.ts +50 -0
- package/extension/learning-session.ts +75 -0
- package/extension/runner/code-runner.ts +275 -0
- package/extension/server/interaction-broker.ts +345 -0
- package/extension/server/learning-server.ts +753 -0
- package/extension/server/sse-hub.ts +63 -0
- package/extension/state/learning-state.ts +458 -0
- package/extension/state/session-persistence.ts +72 -0
- package/extension/tools/ask-code.ts +97 -0
- package/extension/tools/ask-free-response.ts +88 -0
- package/extension/tools/ask-multi-choice.ts +109 -0
- package/extension/tools/ask-record-attempt.ts +50 -0
- package/extension/tools/ask-single-choice.ts +110 -0
- package/extension/tools/index.ts +39 -0
- package/extension/tools/tui-presenter.ts +609 -0
- package/extension/transcript-sync.ts +197 -0
- package/extension/tutor-prompt.ts +64 -0
- package/extension/utils/browser.ts +35 -0
- package/extension/utils/ids.ts +5 -0
- package/extension/utils/validation.ts +138 -0
- package/package.json +82 -0
- package/shared/learning-state.ts +62 -0
- package/shared/protocol.ts +236 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/abap-08VXUWAP.js +1 -0
- package/web/dist/assets/apex-BWPQTe0t.js +1 -0
- package/web/dist/assets/azcli-Bc_sGQ0U.js +1 -0
- package/web/dist/assets/bat-i0X4ZdIN.js +1 -0
- package/web/dist/assets/bicep-B5-_aFwp.js +2 -0
- package/web/dist/assets/cameligo-DMUM7wLl.js +1 -0
- package/web/dist/assets/clojure-Cm7r79vr.js +1 -0
- package/web/dist/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/web/dist/assets/coffee-Ba7i2nA0.js +1 -0
- package/web/dist/assets/cpp-C7h46wYY.js +1 -0
- package/web/dist/assets/csharp-BKxtCVv1.js +1 -0
- package/web/dist/assets/csp-bTuwJoIa.js +1 -0
- package/web/dist/assets/css-DIMkf-bt.js +3 -0
- package/web/dist/assets/css.worker-DRH2OcAG.js +93 -0
- package/web/dist/assets/cssMode-BpvqYhpN.js +1 -0
- package/web/dist/assets/cypher-CVaqCwHa.js +1 -0
- package/web/dist/assets/dart-onAF5SnQ.js +1 -0
- package/web/dist/assets/dockerfile-DZFCIeNp.js +1 -0
- package/web/dist/assets/ecl-D05T4iGw.js +1 -0
- package/web/dist/assets/editor.worker-D9zwrD0f.js +30 -0
- package/web/dist/assets/elixir-6RTg0lbw.js +1 -0
- package/web/dist/assets/flow9-C5_-GSwl.js +1 -0
- package/web/dist/assets/freemarker2-CmTivYCM.js +3 -0
- package/web/dist/assets/fsharp-C8Ef5oNN.js +1 -0
- package/web/dist/assets/go-C-y9NEjX.js +1 -0
- package/web/dist/assets/graphql-fmXr3nnJ.js +1 -0
- package/web/dist/assets/handlebars-ClhpojlY.js +1 -0
- package/web/dist/assets/hcl-CpzslTdj.js +1 -0
- package/web/dist/assets/html-CTqIxFH7.js +1 -0
- package/web/dist/assets/html.worker-DQLddT9h.js +506 -0
- package/web/dist/assets/htmlMode-BKCTQ4_a.js +1 -0
- package/web/dist/assets/index-6N_lnfyE.js +1243 -0
- package/web/dist/assets/index-B5x0MzHm.css +1 -0
- package/web/dist/assets/ini-sBoK_t0W.js +1 -0
- package/web/dist/assets/java-BEtHBSE6.js +1 -0
- package/web/dist/assets/javascript-Cp_q6aWP.js +1 -0
- package/web/dist/assets/json.worker-D_Dqrls5.js +62 -0
- package/web/dist/assets/jsonMode-CMDRrSMX.js +7 -0
- package/web/dist/assets/julia-Bri6UV-V.js +1 -0
- package/web/dist/assets/kotlin-BOotOW0E.js +1 -0
- package/web/dist/assets/less-B9JPFI3C.js +2 -0
- package/web/dist/assets/lexon-CfSJPG6W.js +1 -0
- package/web/dist/assets/liquid-BFK8j46k.js +1 -0
- package/web/dist/assets/lspLanguageFeatures-nT2b0EDJ.js +4 -0
- package/web/dist/assets/lua-CsQS60Ue.js +1 -0
- package/web/dist/assets/m3-D-oSqn_W.js +1 -0
- package/web/dist/assets/markdown-Cimd5fb3.js +1 -0
- package/web/dist/assets/mdx-cEJtnt-p.js +1 -0
- package/web/dist/assets/mips-CIPQ_RoX.js +1 -0
- package/web/dist/assets/msdax-DauUninz.js +1 -0
- package/web/dist/assets/mysql-SOo6toE5.js +1 -0
- package/web/dist/assets/objective-c-FvmIjYaQ.js +1 -0
- package/web/dist/assets/pascal-DrH0SRf2.js +1 -0
- package/web/dist/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/web/dist/assets/perl-oz_6vUea.js +1 -0
- package/web/dist/assets/pgsql-DTj74zXo.js +1 -0
- package/web/dist/assets/php-nr791fC2.js +1 -0
- package/web/dist/assets/pla-CopQ2nXW.js +1 -0
- package/web/dist/assets/postiats-43DmfD33.js +1 -0
- package/web/dist/assets/powerquery-D3hlyOfw.js +1 -0
- package/web/dist/assets/powershell-DmHpPYUd.js +1 -0
- package/web/dist/assets/protobuf-C531GsRP.js +2 -0
- package/web/dist/assets/pug-Z5eAx3Zn.js +1 -0
- package/web/dist/assets/python-bhI_n2_n.js +1 -0
- package/web/dist/assets/qsharp-DkqhCAOL.js +1 -0
- package/web/dist/assets/r-BwWrilGY.js +1 -0
- package/web/dist/assets/razor-D40-I8aA.js +1 -0
- package/web/dist/assets/redis-ClamHrr6.js +1 -0
- package/web/dist/assets/redshift-DT7zqm-g.js +1 -0
- package/web/dist/assets/restructuredtext-BYgofb2h.js +1 -0
- package/web/dist/assets/ruby-DezsRK8O.js +1 -0
- package/web/dist/assets/rust-DdL9SqIa.js +1 -0
- package/web/dist/assets/sb-CcwsVR0C.js +1 -0
- package/web/dist/assets/scala-DHpiXF5c.js +1 -0
- package/web/dist/assets/scheme-BeGwcela.js +1 -0
- package/web/dist/assets/scss-gp-XZpBa.js +3 -0
- package/web/dist/assets/shell-CC2rA5mh.js +1 -0
- package/web/dist/assets/solidity-BEEn4gHE.js +1 -0
- package/web/dist/assets/sophia-CRfGWb83.js +1 -0
- package/web/dist/assets/sparql-D_Lu-MrJ.js +1 -0
- package/web/dist/assets/sql-NEE52Syq.js +1 -0
- package/web/dist/assets/st-DbInun42.js +1 -0
- package/web/dist/assets/swift-Bxkupp3x.js +1 -0
- package/web/dist/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/web/dist/assets/tcl-DISqw1ZD.js +1 -0
- package/web/dist/assets/ts.worker-DkYoA6bF.js +67738 -0
- package/web/dist/assets/tsMode-C9OJ2Aji.js +11 -0
- package/web/dist/assets/twig-De2hgUGE.js +1 -0
- package/web/dist/assets/typescript-CZ5RbSdB.js +1 -0
- package/web/dist/assets/typespec-B8J7ngcE.js +1 -0
- package/web/dist/assets/vb-DV3o63ZY.js +1 -0
- package/web/dist/assets/wgsl-DpFanUEy.js +298 -0
- package/web/dist/assets/workers-qb_EZhxe.js +1 -0
- package/web/dist/assets/xml-BdDnAXLl.js +1 -0
- package/web/dist/assets/yaml-BSYHoVQy.js +1 -0
- 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
|
+
}
|