Skip to content
82 changes: 82 additions & 0 deletions src/domain/hint-state-machine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Pure-logic hint state machine.
*
* The "tutor, not solution oracle" contract is enforced **here** — not in
* prompts, not in tool descriptions, not in the agent's instruction-following.
* Every transition that affects hint progression flows through these
* functions, and gated tools call {@link assertSolutionUnlocked} before
* returning content. If a code path bypasses this module it is a bug.
*
* Intentionally has no IO: takes a {@link SessionState}, returns a new one
* (or throws). The session store handles persistence.
*/
import {
ErrorCode,
LeetCodeError,
MAX_HINT_LEVEL,
type HintLevel,
type SessionState
} from "../types/index.js";

/** Hint level at which the canonical solution becomes callable. */
export const SOLUTION_HINT_LEVEL: HintLevel = MAX_HINT_LEVEL;

/**
* Bumps `session.hintLevel` by one (clamped at {@link MAX_HINT_LEVEL}) and
* stamps `updatedAt`. Returns a new object — the input is not mutated.
*
* Bumping at the maximum level is a no-op rather than an error: callers
* that want a different behaviour should check `session.hintLevel` before
* calling.
*/
export function advanceHint(session: SessionState): SessionState {
const next: HintLevel =
session.hintLevel >= MAX_HINT_LEVEL
? MAX_HINT_LEVEL
: ((session.hintLevel + 1) as HintLevel);
return {
...session,
hintLevel: next,
updatedAt: new Date().toISOString()
};
}

/**
* Resets the session back to its level-0 initial state, preserving the
* slug / language / workspace so the user can re-attempt from scratch.
*
* `attempts` and `lastLocalRunPassed` are zeroed too, because resetting
* the hint level without resetting effort would mislead future hint
* generation about how much the user has already tried.
*/
export function resetSession(session: SessionState): SessionState {
return {
...session,
hintLevel: 0,
attempts: 0,
lastLocalRunPassed: null,
status: "started",
updatedAt: new Date().toISOString()
};
}

/**
* Throws `LeetCodeError(HINT_LEVEL_TOO_LOW)` unless the session has
* reached the level required to unlock the canonical solution.
*
* `list_problem_solutions` and `get_problem_solution` MUST call this
* before returning content. If the session doesn't exist (the user
* never called `start_problem`) callers should throw
* `SESSION_NOT_FOUND` themselves — that's a different failure mode and
* the agent should react differently to it.
*/
export function assertSolutionUnlocked(session: SessionState): void {
if (session.hintLevel < SOLUTION_HINT_LEVEL) {
throw new LeetCodeError(
ErrorCode.HINT_LEVEL_TOO_LOW,
`Solution is gated behind hint level ${SOLUTION_HINT_LEVEL}; ` +
`session for "${session.slug}" is at level ${session.hintLevel}. ` +
`Drive the user through \`request_hint\` until they have engaged with each level.`
);
}
}
72 changes: 72 additions & 0 deletions src/domain/pedagogy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Generates per-level hint text for a given problem.
*
* Phase 3 ships generic hints derived from the problem's existing
* `hints` and `topicTags`. Phase 5 (workspace awareness) extends this by
* accepting the user's actual code so the level-2/3 messages can
* critique what they wrote rather than describing the problem in the
* abstract. The `userCode` parameter is already in the signature to keep
* the contract stable across phases.
*/
import type { HintLevel, SimplifiedProblem } from "../types/index.js";

/**
* Pure projection from problem + level → hint text. No IO.
*
* The contract per level matches `HintLevel`'s docstring:
* 1 — clarification (restate, edge cases)
* 2 — approach (paradigm / data structure)
* 3 — implementation sketch (pseudocode-level)
* 4 — optimal (full solution; the agent should call
* `get_problem_solution` once this level is reached, not paraphrase)
*
* Level 0 is "no hint requested yet" and is never produced by this
* function — callers should never ask for it.
*
* `userCode` is reserved for Phase 5; if provided, future levels will
* incorporate it. The Phase 3 implementation ignores it.
*/
export function generateHint(
problem: SimplifiedProblem,
level: Exclude<HintLevel, 0>,
_userCode?: string
): string {
switch (level) {
case 1:
return level1(problem);
case 2:
return level2(problem);
case 3:
return level3(problem);
case 4:
return level4(problem);
}
}

function level1(problem: SimplifiedProblem): string {
const examples = problem.exampleTestcases?.trim();
const examplePart = examples
? `\n\nWalk through the example inputs and the expected outputs in your own words:\n\n\`\`\`\n${examples}\n\`\`\`\n\nWhat invariants must hold? What edge cases worry you?`
: "\n\nWhat invariants must hold? What edge cases worry you?";
return `Level 1 — Clarification.\n\nRestate **${problem.title}** in your own words. What are the inputs and outputs? What constraints does the problem impose on size, value range, or duplicates?${examplePart}`;
}

function level2(problem: SimplifiedProblem): string {
const tags = problem.topicTags?.join(", ");
const tagPart = tags
? ` The problem is tagged: \`${tags}\`. Which of those is the most natural fit?`
: "";
return `Level 2 — Approach.\n\nWhat data structure or algorithmic paradigm does this map onto?${tagPart}\n\nThink about the asymptotic cost of the obvious O(n²) brute force and what structure would let you get to O(n) or O(n log n) — without writing any code yet.`;
}

function level3(problem: SimplifiedProblem): string {
const upstream = problem.hints?.[0]?.trim();
const upstreamPart = upstream
? `\n\nLeetCode's own first hint:\n\n> ${upstream}`
: "";
return `Level 3 — Implementation sketch.\n\nNow draft the algorithm at pseudocode level. Walk through the data structures you'll allocate, the loop boundaries, what each iteration updates, and how you produce the final answer. Don't write language syntax yet — just the steps.${upstreamPart}`;
}

function level4(problem: SimplifiedProblem): string {
return `Level 4 — Solution unlocked.\n\nThe session for **${problem.title}** has reached the maximum hint level. \`get_problem_solution\` and \`list_problem_solutions\` are now callable — prefer fetching the canonical solution over paraphrasing it.`;
}
145 changes: 145 additions & 0 deletions src/domain/session-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Application-layer wrapper around the session store + state machine +
* hint generator. Tools should depend on this, not on the lower-level
* pieces directly — it's the seam that makes the gate uniform.
*/
import {
ErrorCode,
LeetCodeError,
type HintLevel,
type SessionState,
type SimplifiedProblem
} from "../types/index.js";
import {
advanceHint,
assertSolutionUnlocked,
resetSession
} from "./hint-state-machine.js";
import { generateHint } from "./pedagogy.js";
import { FileSessionStore, type SessionStore } from "./session-store.js";

export interface StartProblemInput {
slug: string;
language?: string;
}

export class SessionService {
constructor(
private readonly store: SessionStore = new FileSessionStore()
) {}

/**
* Returns the existing session for a slug, or creates a fresh
* level-0 session if none exists. Idempotent: starting a problem the
* user already started just returns the in-progress session
* unchanged (so you don't lose hint progress by re-running
* `start_problem`).
*/
async startOrResume(input: StartProblemInput): Promise<SessionState> {
const existing = await this.store.load(input.slug);
if (existing) {
// Update language only if the caller specified one and we
// didn't have one before — never silently overwrite.
if (input.language && !existing.language) {
const updated: SessionState = {
...existing,
language: input.language,
updatedAt: new Date().toISOString()
};
await this.store.save(updated);
return updated;
}
return existing;
}
const now = new Date().toISOString();
const fresh: SessionState = {
slug: input.slug,
language: input.language,
hintLevel: 0,
attempts: 0,
lastLocalRunPassed: null,
status: "started",
createdAt: now,
updatedAt: now
};
await this.store.save(fresh);
return fresh;
}

/** Returns the session, or `null` if `start_problem` was never called. */
async get(slug: string): Promise<SessionState | null> {
return this.store.load(slug);
}

/**
* Advances the hint level by one and returns the new session +
* generated hint text. The text is produced from the supplied
* problem so callers don't need to load it twice.
*
* Throws `SESSION_NOT_FOUND` if the user never opened the problem.
*/
async advance(
slug: string,
problem: SimplifiedProblem
): Promise<{ session: SessionState; hint: string; level: HintLevel }> {
const session = await this.requireSession(slug);
const next = advanceHint(session);
await this.store.save(next);
const level = next.hintLevel;
if (level === 0) {
// Unreachable — advanceHint never returns 0 — but the type
// narrows from HintLevel to 1..4 only with this guard.
throw new LeetCodeError(
ErrorCode.UPSTREAM_ERROR,
"Hint level transition produced level 0"
);
}
return {
session: next,
level,
hint: generateHint(problem, level)
};
}

/** Resets the session back to the level-0 initial state. */
async reset(slug: string): Promise<SessionState> {
const session = await this.requireSession(slug);
const next = resetSession(session);
await this.store.save(next);
return next;
}

/**
* Throws if the canonical solution is not yet unlocked for `slug`.
* If `slug` is undefined, accepts the operation when *any* known
* session has reached the maximum level — the only way for the
* agent to obtain a `topicId` is via `list_problem_solutions`,
* which IS slug-gated, so this is sufficient for the typical flow.
*/
async assertSolutionUnlocked(slug?: string): Promise<void> {
if (slug) {
const session = await this.requireSession(slug);
assertSolutionUnlocked(session);
return;
}
// No slug provided. We can't enumerate sessions without a
// discovery API on the store; defer to the caller to provide
// slug context. This branch is reserved for future expansion.
throw new LeetCodeError(
ErrorCode.HINT_LEVEL_TOO_LOW,
"Cannot determine session context without titleSlug. " +
"Provide titleSlug to verify the session has reached the required hint level."
);
}

private async requireSession(slug: string): Promise<SessionState> {
const session = await this.store.load(slug);
if (!session) {
throw new LeetCodeError(
ErrorCode.SESSION_NOT_FOUND,
`No active session for "${slug}". Call start_problem first.`
);
}
return session;
}
}
Loading
Loading