handzon-core 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/collections.ts +97 -3
  3. package/src/components/Sidebar.astro +5 -2
  4. package/src/components/ai/ChatButton.tsx +51 -3
  5. package/src/components/ai/ChatPanel.tsx +86 -23
  6. package/src/components/ai/CopyStep.tsx +44 -0
  7. package/src/components/ai/OpenInAgent.tsx +55 -0
  8. package/src/components/ai/SelectionAsk.tsx +98 -0
  9. package/src/components/ai/StepHelp.tsx +31 -0
  10. package/src/components/home/Hero.astro +4 -3
  11. package/src/components/mdx/Checkpoint.tsx +66 -2
  12. package/src/components/mdx/CopyPrompt.astro +10 -0
  13. package/src/components/mdx/CopyPrompt.tsx +56 -0
  14. package/src/components/mdx/HelpMe.astro +10 -0
  15. package/src/components/mdx/HelpMe.tsx +29 -0
  16. package/src/components/mdx/Playground.tsx +61 -9
  17. package/src/components/mdx/Quiz.tsx +18 -0
  18. package/src/index.ts +5 -0
  19. package/src/layouts/BaseLayout.astro +6 -0
  20. package/src/layouts/TutorialLayout.astro +37 -9
  21. package/src/lib/ai/assist.ts +81 -0
  22. package/src/lib/ai/prompts.ts +126 -0
  23. package/src/lib/ai/stepData.ts +74 -0
  24. package/src/lib/mdx-components.ts +4 -0
  25. package/src/lib/progress/remote.ts +86 -25
  26. package/src/lib/progress/types.ts +23 -0
  27. package/src/lib/progress/useProgress.ts +8 -4
  28. package/src/pages/Home.astro +7 -1
  29. package/src/pages/TutorialLanding.astro +6 -4
  30. package/src/pages/TutorialStep.astro +13 -1
  31. package/src/server/auth.ts +84 -1
  32. package/src/server/db/schema.ts +53 -0
  33. package/src/server/handlers/helpInbox.ts +45 -0
  34. package/src/server/handlers/mcp.ts +72 -0
  35. package/src/server/handlers/progress.ts +7 -51
  36. package/src/server/handlers/progressEvents.ts +68 -0
  37. package/src/server/mcp/protocol.ts +99 -0
  38. package/src/server/mcp/server.ts +94 -0
  39. package/src/server/mcp/tools.ts +175 -0
  40. package/src/server/mcp/writeTools.ts +407 -0
  41. package/src/server/progress.ts +86 -0
  42. package/src/server/progressBus.ts +51 -0
  43. package/src/server/tokens.ts +80 -0
  44. package/src/server/verify/evaluator.ts +134 -0
  45. package/src/types/ai.ts +6 -0
  46. package/styles/base.css +16 -12
  47. package/styles/components/assist.css +101 -0
  48. package/styles/components/checkpoint.css +29 -0
  49. package/styles/components.css +1 -0
@@ -1,10 +1,11 @@
1
1
  import type { APIRoute } from "astro";
2
- import { and, eq, sql } from "drizzle-orm";
2
+ import { eq } from "drizzle-orm";
3
3
  import { z } from "zod";
4
4
  import { getOrCreateLearner } from "../auth.ts";
5
5
  import { getDb } from "../db/client.ts";
6
6
  import { progressEntries } from "../db/schema.ts";
7
7
  import { isSameOrigin, json } from "../http.ts";
8
+ import { writeProgressEntries } from "../progress.ts";
8
9
 
9
10
  const MAX_BODY_BYTES = 32 * 1024;
10
11
  const MAX_ENTRIES = 200;
@@ -59,55 +60,10 @@ export const POST: APIRoute = async ({ cookies, request }) => {
59
60
  if (parsed.length === 0) return json({ written: 0 });
60
61
 
61
62
  const learner = await getOrCreateLearner(cookies, request);
62
- const db = getDb();
63
- const now = new Date();
64
-
65
63
  // `value: null` is the tombstone signal for "this entry was undone"
66
- // (e.g. unchecking a checkpoint). The `value` column is NOT NULL, so
67
- // we DELETE these rows instead of upserting them.
68
- const deletes = parsed.filter((b) => b.value === null);
69
- const upserts = parsed.filter((b) => b.value !== null);
70
-
71
- for (const d of deletes) {
72
- await db
73
- .delete(progressEntries)
74
- .where(
75
- and(
76
- eq(progressEntries.learnerId, learner.id),
77
- eq(progressEntries.kind, d.kind),
78
- eq(progressEntries.scope, d.scope),
79
- eq(progressEntries.key, d.key),
80
- ),
81
- );
82
- }
83
-
84
- if (upserts.length > 0) {
85
- const rows = upserts.map((b) => ({
86
- learnerId: learner.id,
87
- kind: b.kind,
88
- scope: b.scope,
89
- key: b.key,
90
- value: b.value,
91
- updatedAt: now,
92
- }));
93
- await db
94
- .insert(progressEntries)
95
- .values(rows)
96
- .onConflictDoUpdate({
97
- target: [
98
- progressEntries.learnerId,
99
- progressEntries.kind,
100
- progressEntries.scope,
101
- progressEntries.key,
102
- ],
103
- set: {
104
- // `excluded` is the row Postgres would have inserted — without
105
- // this the SET was a no-op (`value = progress_entries.value`).
106
- value: sql`excluded.value`,
107
- updatedAt: sql`excluded.updated_at`,
108
- },
109
- });
110
- }
111
-
112
- return json({ written: parsed.length });
64
+ // (e.g. unchecking a checkpoint). writeProgressEntries handles the
65
+ // split into deletes + upserts; the same writer is used by the MCP
66
+ // write tools so behaviour stays consistent across surfaces.
67
+ const written = await writeProgressEntries(learner.id, parsed);
68
+ return json({ written });
113
69
  };
@@ -0,0 +1,68 @@
1
+ import type { APIRoute } from "astro";
2
+ import { getOrCreateLearner } from "../auth.ts";
3
+ import { isSameOrigin } from "../http.ts";
4
+ import { subscribeLearner } from "../progressBus.ts";
5
+
6
+ const HEARTBEAT_MS = 25_000;
7
+
8
+ /**
9
+ * Server-Sent Events stream of progress writes for the current
10
+ * learner. One connection per open browser tab; the in-app store
11
+ * (`createRemoteStore`) opens an EventSource on first mount and
12
+ * merges incoming entries through the same reducer as the
13
+ * POST-response path.
14
+ *
15
+ * Each event has the shape:
16
+ * data: { "kind": "...", "scope": "...", "key": "...", "value": ..., "ts": ... }
17
+ *
18
+ * Heartbeat comments keep proxies (CDN, Render's edge) from
19
+ * killing the connection on idle.
20
+ */
21
+ export const GET: APIRoute = async ({ cookies, request }) => {
22
+ if (!process.env.DATABASE_URL) {
23
+ return new Response("SSE disabled — no DATABASE_URL.", { status: 503 });
24
+ }
25
+ if (!isSameOrigin(request)) {
26
+ return new Response("Cross-origin SSE rejected.", { status: 403 });
27
+ }
28
+ const learner = await getOrCreateLearner(cookies, request);
29
+ const encoder = new TextEncoder();
30
+ let unsubscribe: (() => void) | null = null;
31
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
32
+
33
+ const stream = new ReadableStream<Uint8Array>({
34
+ start(controller) {
35
+ // Initial connect comment — clients show "open" only after a
36
+ // first bit of data, and some proxies need a flush.
37
+ controller.enqueue(encoder.encode(": connected\n\n"));
38
+ unsubscribe = subscribeLearner(learner.id, (msg) => {
39
+ try {
40
+ const payload = JSON.stringify(msg);
41
+ controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
42
+ } catch (e) {
43
+ console.warn("[handzon] sse encode failed:", e);
44
+ }
45
+ });
46
+ heartbeat = setInterval(() => {
47
+ try {
48
+ controller.enqueue(encoder.encode(": keepalive\n\n"));
49
+ } catch {
50
+ /* controller closed — cancel will clean up */
51
+ }
52
+ }, HEARTBEAT_MS);
53
+ },
54
+ cancel() {
55
+ unsubscribe?.();
56
+ if (heartbeat) clearInterval(heartbeat);
57
+ },
58
+ });
59
+
60
+ return new Response(stream, {
61
+ headers: {
62
+ "Content-Type": "text/event-stream",
63
+ "Cache-Control": "no-cache, no-transform",
64
+ Connection: "keep-alive",
65
+ "X-Accel-Buffering": "no",
66
+ },
67
+ });
68
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Minimal MCP-compatible JSON-RPC 2.0 dispatcher.
3
+ *
4
+ * We hand-roll the surface (rather than depending on
5
+ * @modelcontextprotocol/sdk) because the SDK targets Node http
6
+ * I/O, while Astro endpoints run on the Fetch Request/Response
7
+ * API. The Streamable HTTP transport supports immediate JSON
8
+ * responses, which is all v1 needs — SSE-streamed tool results
9
+ * are not on the v1 menu.
10
+ *
11
+ * Supported methods:
12
+ * - initialize → server capabilities + name/version
13
+ * - tools/list → ordered tool descriptors
14
+ * - tools/call → execute a tool with arguments
15
+ *
16
+ * Anything else returns -32601 Method not found.
17
+ */
18
+
19
+ export const PROTOCOL_VERSION = "2025-03-26";
20
+
21
+ export interface JsonRpcRequest {
22
+ jsonrpc: "2.0";
23
+ id?: string | number | null;
24
+ method: string;
25
+ params?: unknown;
26
+ }
27
+
28
+ export interface JsonRpcResult {
29
+ jsonrpc: "2.0";
30
+ id: string | number | null;
31
+ result: unknown;
32
+ }
33
+
34
+ export interface JsonRpcError {
35
+ jsonrpc: "2.0";
36
+ id: string | number | null;
37
+ error: { code: number; message: string; data?: unknown };
38
+ }
39
+
40
+ export type JsonRpcResponse = JsonRpcResult | JsonRpcError;
41
+
42
+ export interface McpToolContent {
43
+ type: "text";
44
+ text: string;
45
+ }
46
+
47
+ export interface McpToolResult {
48
+ content: McpToolContent[];
49
+ isError?: boolean;
50
+ }
51
+
52
+ export interface McpTool<A = unknown> {
53
+ name: string;
54
+ description: string;
55
+ /** JSON Schema for the tool arguments. */
56
+ inputSchema: Record<string, unknown>;
57
+ handler: (args: A, ctx: McpContext) => Promise<McpToolResult>;
58
+ /** Set when the tool mutates state — used to gate by scope. */
59
+ requiredScope?: string;
60
+ }
61
+
62
+ export interface McpContext {
63
+ /** Resolved learner id when an authenticated tool is invoked. */
64
+ learnerId?: string;
65
+ /** Token scopes granted to the caller. */
66
+ scopes?: string[];
67
+ /** Original request — tools that need cookies, IP, etc. read from here. */
68
+ request: Request;
69
+ }
70
+
71
+ export interface ServerInfo {
72
+ name: string;
73
+ version: string;
74
+ }
75
+
76
+ export function ok(id: JsonRpcRequest["id"], result: unknown): JsonRpcResult {
77
+ return { jsonrpc: "2.0", id: id ?? null, result };
78
+ }
79
+
80
+ export function fail(
81
+ id: JsonRpcRequest["id"],
82
+ code: number,
83
+ message: string,
84
+ data?: unknown,
85
+ ): JsonRpcError {
86
+ return {
87
+ jsonrpc: "2.0",
88
+ id: id ?? null,
89
+ error: data === undefined ? { code, message } : { code, message, data },
90
+ };
91
+ }
92
+
93
+ export function text(value: string): McpToolResult {
94
+ return { content: [{ type: "text", text: value }] };
95
+ }
96
+
97
+ export function errorResult(message: string): McpToolResult {
98
+ return { content: [{ type: "text", text: message }], isError: true };
99
+ }
@@ -0,0 +1,94 @@
1
+ import {
2
+ fail,
3
+ type JsonRpcRequest,
4
+ type JsonRpcResponse,
5
+ type McpContext,
6
+ type McpTool,
7
+ ok,
8
+ PROTOCOL_VERSION,
9
+ type ServerInfo,
10
+ } from "./protocol.ts";
11
+
12
+ const DEFAULT_INFO: ServerInfo = {
13
+ name: "handzon-mcp",
14
+ version: "0.1.0",
15
+ };
16
+
17
+ export interface DispatchOptions {
18
+ /** Tools registered with the server. */
19
+ tools: McpTool[];
20
+ /** Identity declared to MCP clients. Overridable per-site. */
21
+ serverInfo?: Partial<ServerInfo>;
22
+ /**
23
+ * Resolve the caller's learner id + scopes from the request, if any.
24
+ * Catalog reads don't need this; protected tools do. When null is
25
+ * returned for a tool with `requiredScope`, the call fails with
26
+ * -32001 Unauthorized.
27
+ */
28
+ resolveAuth?: (request: Request) => Promise<{ learnerId: string; scopes: string[] } | null>;
29
+ }
30
+
31
+ /**
32
+ * Dispatch one JSON-RPC request against the configured tool set.
33
+ * Pure of HTTP — the Astro endpoint owns request parsing, response
34
+ * serialization, and CORS. This function returns whatever the JSON-RPC
35
+ * spec wants in the response body.
36
+ */
37
+ export async function dispatchMcp(
38
+ request: Request,
39
+ body: JsonRpcRequest,
40
+ opts: DispatchOptions,
41
+ ): Promise<JsonRpcResponse> {
42
+ const info = { ...DEFAULT_INFO, ...(opts.serverInfo ?? {}) };
43
+
44
+ if (body.method === "initialize") {
45
+ return ok(body.id, {
46
+ protocolVersion: PROTOCOL_VERSION,
47
+ capabilities: { tools: { listChanged: false } },
48
+ serverInfo: info,
49
+ });
50
+ }
51
+
52
+ if (body.method === "tools/list") {
53
+ const tools = opts.tools.map((t) => ({
54
+ name: t.name,
55
+ description: t.description,
56
+ inputSchema: t.inputSchema,
57
+ }));
58
+ return ok(body.id, { tools });
59
+ }
60
+
61
+ if (body.method === "tools/call") {
62
+ const params = body.params as { name?: string; arguments?: unknown } | undefined;
63
+ const toolName = params?.name;
64
+ if (!toolName) return fail(body.id, -32602, "Missing tool name.");
65
+ const tool = opts.tools.find((t) => t.name === toolName);
66
+ if (!tool) return fail(body.id, -32601, `Unknown tool: ${toolName}`);
67
+
68
+ let learnerId: string | undefined;
69
+ let scopes: string[] | undefined;
70
+ if (opts.resolveAuth) {
71
+ const resolved = await opts.resolveAuth(request);
72
+ if (resolved) {
73
+ learnerId = resolved.learnerId;
74
+ scopes = resolved.scopes;
75
+ }
76
+ }
77
+ if (tool.requiredScope) {
78
+ if (!scopes?.includes(tool.requiredScope)) {
79
+ return fail(body.id, -32001, `Missing required scope: ${tool.requiredScope}`);
80
+ }
81
+ }
82
+
83
+ const ctx: McpContext = { request, learnerId, scopes };
84
+ try {
85
+ const result = await tool.handler(params?.arguments ?? {}, ctx);
86
+ return ok(body.id, result);
87
+ } catch (e) {
88
+ const message = e instanceof Error ? e.message : String(e);
89
+ return fail(body.id, -32603, "Tool execution failed.", { message });
90
+ }
91
+ }
92
+
93
+ return fail(body.id, -32601, `Unknown method: ${body.method}`);
94
+ }
@@ -0,0 +1,175 @@
1
+ import { eq } from "drizzle-orm";
2
+ import {
3
+ getStep,
4
+ getStepsForTutorial,
5
+ getTutorialBySlug,
6
+ getTutorials,
7
+ parseStepId,
8
+ } from "../../lib/content.ts";
9
+ import { getDb } from "../db/client.ts";
10
+ import { progressEntries } from "../db/schema.ts";
11
+ import { type McpTool, text } from "./protocol.ts";
12
+ import { progressWriteTools, verificationTools } from "./writeTools.ts";
13
+
14
+ /**
15
+ * Pull the first <Checkpoint label="…"> from a step body so the
16
+ * agent can surface the prose criterion when there's no machine-
17
+ * verifiable spec to run. Returns undefined when no Checkpoint or
18
+ * no label attribute is present.
19
+ */
20
+ function extractCheckpointLabel(body: string): string | undefined {
21
+ const m = /<Checkpoint\b[^>]*\blabel\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/.exec(body);
22
+ if (!m) return undefined;
23
+ return m[1] ?? m[2] ?? m[3];
24
+ }
25
+
26
+ /**
27
+ * Catalog read tools. No auth required beyond a valid bearer token —
28
+ * agents browsing what's available before deciding which tutorial to
29
+ * help with don't need progress:read.
30
+ */
31
+ export const catalogReadTools: McpTool[] = [
32
+ {
33
+ name: "list_tutorials",
34
+ description: "List every tutorial published on this Handzon site.",
35
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
36
+ handler: async () => {
37
+ const tutorials = await getTutorials();
38
+ const rows = tutorials.map((t) => ({
39
+ slug: t.id,
40
+ title: t.data.title,
41
+ description: t.data.description,
42
+ difficulty: t.data.difficulty,
43
+ tags: t.data.tags,
44
+ }));
45
+ return text(JSON.stringify({ tutorials: rows }, null, 2));
46
+ },
47
+ },
48
+ {
49
+ name: "get_tutorial",
50
+ description:
51
+ "Return tutorial metadata + ordered step outline (slug, title, duration) for one tutorial.",
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: { slug: { type: "string", minLength: 1 } },
55
+ required: ["slug"],
56
+ additionalProperties: false,
57
+ },
58
+ handler: async (args) => {
59
+ const { slug } = args as { slug: string };
60
+ const tutorial = await getTutorialBySlug(slug);
61
+ if (!tutorial) {
62
+ return {
63
+ content: [{ type: "text", text: `No tutorial with slug "${slug}".` }],
64
+ isError: true,
65
+ };
66
+ }
67
+ const steps = await getStepsForTutorial(slug);
68
+ const payload = {
69
+ slug: tutorial.id,
70
+ title: tutorial.data.title,
71
+ description: tutorial.data.description,
72
+ difficulty: tutorial.data.difficulty,
73
+ tags: tutorial.data.tags,
74
+ gated: tutorial.data.gated,
75
+ steps: steps.map((s) => {
76
+ const { stepSlug, order } = parseStepId(s.id);
77
+ return {
78
+ slug: stepSlug,
79
+ order,
80
+ title: s.data.title,
81
+ summary: s.data.summary,
82
+ duration: s.data.duration,
83
+ };
84
+ }),
85
+ };
86
+ return text(JSON.stringify(payload, null, 2));
87
+ },
88
+ },
89
+ {
90
+ name: "get_step",
91
+ description: "Return one step's full Markdown source + metadata.",
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ tutorial: { type: "string", minLength: 1 },
96
+ step: { type: "string", minLength: 1 },
97
+ },
98
+ required: ["tutorial", "step"],
99
+ additionalProperties: false,
100
+ },
101
+ handler: async (args) => {
102
+ const { tutorial, step } = args as { tutorial: string; step: string };
103
+ const tut = await getTutorialBySlug(tutorial);
104
+ if (!tut) {
105
+ return {
106
+ content: [{ type: "text", text: `No tutorial with slug "${tutorial}".` }],
107
+ isError: true,
108
+ };
109
+ }
110
+ const stepEntry = await getStep(tutorial, step);
111
+ if (!stepEntry) {
112
+ return {
113
+ content: [{ type: "text", text: `No step "${step}" in "${tutorial}".` }],
114
+ isError: true,
115
+ };
116
+ }
117
+ const { stepSlug, order } = parseStepId(stepEntry.id);
118
+ const body = stepEntry.body ?? "";
119
+ const verifySpec = (stepEntry.data as { verify?: unknown }).verify;
120
+ const payload = {
121
+ tutorial,
122
+ slug: stepSlug,
123
+ order,
124
+ title: stepEntry.data.title,
125
+ summary: stepEntry.data.summary,
126
+ duration: stepEntry.data.duration,
127
+ source: body,
128
+ verify: verifySpec ?? null,
129
+ // Prose-fallback: when no verify block is declared, surface the
130
+ // <Checkpoint label="…"> text so the agent can self-attest the
131
+ // criterion before calling complete_checkpoint.
132
+ checkpointCriterion: verifySpec ? null : (extractCheckpointLabel(body) ?? null),
133
+ };
134
+ return text(JSON.stringify(payload, null, 2));
135
+ },
136
+ },
137
+ ];
138
+
139
+ /**
140
+ * Authenticated read tools. Available to any valid bearer token (no
141
+ * `requiredScope` because reading your own progress is implicit in
142
+ * holding a PAT for the account).
143
+ */
144
+ export const progressReadTools: McpTool[] = [
145
+ {
146
+ name: "get_progress",
147
+ description: "Return every progress entry for the authenticated learner.",
148
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
149
+ handler: async (_args, ctx) => {
150
+ if (!ctx.learnerId) {
151
+ return {
152
+ content: [{ type: "text", text: "No resolved learner — bearer token required." }],
153
+ isError: true,
154
+ };
155
+ }
156
+ const db = getDb();
157
+ const rows = await db
158
+ .select()
159
+ .from(progressEntries)
160
+ .where(eq(progressEntries.learnerId, ctx.learnerId));
161
+ return text(JSON.stringify({ entries: rows }, null, 2));
162
+ },
163
+ },
164
+ ];
165
+
166
+ /**
167
+ * Default tool bundle the scaffold mounts. Order matters for the
168
+ * tools/list response — clients usually surface them in array order.
169
+ */
170
+ export const defaultTools: McpTool[] = [
171
+ ...catalogReadTools,
172
+ ...progressReadTools,
173
+ ...progressWriteTools,
174
+ ...verificationTools,
175
+ ];