Something went wrong. Try again.
Cursor plugin specification and official plugins
Something went wrong. Try again.
7.6 kB · 233 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234/// <reference types="bun-types-no-globals/lib/index.d.ts" />
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";import { dirname, resolve } from "node:path";import { stdin } from "bun";
const STATE_PATH = resolve(".cursor/hooks/state/continuous-learning.json");const INCREMENTAL_INDEX_PATH = resolve( ".cursor/hooks/state/continuous-learning-index.json");const DEFAULT_MIN_TURNS = 10;const DEFAULT_MIN_MINUTES = 120;const TRIAL_DEFAULT_MIN_TURNS = 3;const TRIAL_DEFAULT_MIN_MINUTES = 15;const TRIAL_DEFAULT_DURATION_MINUTES = 24 * 60;
const FOLLOWUP_MESSAGE = `Run the \`continuous-learning\` skill now. First read existing \`AGENTS.md\` and update existing entries in place (do not only append). Use incremental transcript processing with index file \`${INCREMENTAL_INDEX_PATH}\`: only read transcripts not in the index or transcripts whose mtime is newer than indexed mtime (re-read changed transcripts). After processing, write back the updated index mtimes and remove entries for deleted transcripts. Update \`AGENTS.md\` only for high-signal, repeated user-correction patterns or durable workspace facts. Exclude one-off/transient details and secrets. Keep each learned section to at most 12 bullets. Write plain bullet points only, with no evidence/confidence tags or other metadata annotations. If no meaningful updates exist, respond exactly: No high-signal memory updates.`;
interface StopHookInput { conversation_id: string; generation_id?: string; status: "completed" | "aborted" | "error" | string; loop_count: number; transcript_path?: string | null;}
interface ContinuousLearningState { version: 1; lastRunAtMs: number; turnsSinceLastRun: number; lastTranscriptMtimeMs: number | null; lastProcessedGenerationId: string | null; trialStartedAtMs: number | null;}
function parsePositiveInt(value: string | undefined, fallback: number): number { if (!value) { return fallback; } const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } return parsed;}
function parseBoolean(value: string | undefined): boolean { if (!value) { return false; } const normalized = value.trim().toLowerCase(); return ( normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on" );}
function loadState(): ContinuousLearningState { const fallback: ContinuousLearningState = { version: 1, lastRunAtMs: 0, turnsSinceLastRun: 0, lastTranscriptMtimeMs: null, lastProcessedGenerationId: null, trialStartedAtMs: null, };
if (!existsSync(STATE_PATH)) { return fallback; }
try { const raw = readFileSync(STATE_PATH, "utf-8"); const parsed = JSON.parse(raw) as Partial<ContinuousLearningState>; if (parsed.version !== 1) { return fallback; } return { version: 1, lastRunAtMs: typeof parsed.lastRunAtMs === "number" && Number.isFinite(parsed.lastRunAtMs) ? parsed.lastRunAtMs : 0, turnsSinceLastRun: typeof parsed.turnsSinceLastRun === "number" && Number.isFinite(parsed.turnsSinceLastRun) && parsed.turnsSinceLastRun >= 0 ? parsed.turnsSinceLastRun : 0, lastTranscriptMtimeMs: typeof parsed.lastTranscriptMtimeMs === "number" && Number.isFinite(parsed.lastTranscriptMtimeMs) ? parsed.lastTranscriptMtimeMs : null, lastProcessedGenerationId: typeof parsed.lastProcessedGenerationId === "string" ? parsed.lastProcessedGenerationId : null, trialStartedAtMs: typeof parsed.trialStartedAtMs === "number" && Number.isFinite(parsed.trialStartedAtMs) ? parsed.trialStartedAtMs : null, }; } catch { return fallback; }}
function saveState(state: ContinuousLearningState): void { const directory = dirname(STATE_PATH); if (!existsSync(directory)) { mkdirSync(directory, { recursive: true }); } writeFileSync(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf-8");}
function getTranscriptMtimeMs(transcriptPath: string | null | undefined): number | null { if (!transcriptPath) { return null; }
try { return statSync(transcriptPath).mtimeMs; } catch { return null; }}
function shouldCountTurn(input: StopHookInput): boolean { return input.status === "completed" && input.loop_count === 0;}
async function parseHookInput<T>(): Promise<T> { const text = await stdin.text(); return JSON.parse(text) as T;}
async function main(args: string[]): Promise<number> { try { const input = await parseHookInput<StopHookInput>(); const state = loadState();
if (input.generation_id && input.generation_id === state.lastProcessedGenerationId) { console.log(JSON.stringify({})); return 0; } state.lastProcessedGenerationId = input.generation_id ?? null;
const countedTurn = shouldCountTurn(input); const turnIncrement = countedTurn ? 1 : 0; const turnsSinceLastRun = state.turnsSinceLastRun + turnIncrement; const now = Date.now();
const trialEnabled = args.includes("--trial") || parseBoolean(process.env.CONTINUOUS_LEARNING_TRIAL_MODE); if (trialEnabled && countedTurn && state.trialStartedAtMs === null) { state.trialStartedAtMs = now; }
const trialDurationMinutes = parsePositiveInt( process.env.CONTINUOUS_LEARNING_TRIAL_DURATION_MINUTES, TRIAL_DEFAULT_DURATION_MINUTES ); const trialMinTurns = parsePositiveInt( process.env.CONTINUOUS_LEARNING_TRIAL_MIN_TURNS, TRIAL_DEFAULT_MIN_TURNS ); const trialMinMinutes = parsePositiveInt( process.env.CONTINUOUS_LEARNING_TRIAL_MIN_MINUTES, TRIAL_DEFAULT_MIN_MINUTES ); const inTrialWindow = trialEnabled && state.trialStartedAtMs !== null && now - state.trialStartedAtMs < trialDurationMinutes * 60_000;
const minTurns = parsePositiveInt( process.env.CONTINUOUS_LEARNING_MIN_TURNS, DEFAULT_MIN_TURNS ); const minMinutes = parsePositiveInt( process.env.CONTINUOUS_LEARNING_MIN_MINUTES, DEFAULT_MIN_MINUTES );
const effectiveMinTurns = inTrialWindow ? trialMinTurns : minTurns; const effectiveMinMinutes = inTrialWindow ? trialMinMinutes : minMinutes; const minutesSinceLastRun = state.lastRunAtMs > 0 ? Math.floor((now - state.lastRunAtMs) / 60000) : Number.POSITIVE_INFINITY; const transcriptMtimeMs = getTranscriptMtimeMs(input.transcript_path); const hasTranscriptAdvanced = transcriptMtimeMs !== null && (state.lastTranscriptMtimeMs === null || transcriptMtimeMs > state.lastTranscriptMtimeMs);
const shouldTrigger = countedTurn && turnsSinceLastRun >= effectiveMinTurns && minutesSinceLastRun >= effectiveMinMinutes && hasTranscriptAdvanced;
if (shouldTrigger) { state.lastRunAtMs = now; state.turnsSinceLastRun = 0; state.lastTranscriptMtimeMs = transcriptMtimeMs; saveState(state);
console.log( JSON.stringify({ followup_message: FOLLOWUP_MESSAGE, }) ); return 0; }
state.turnsSinceLastRun = turnsSinceLastRun; saveState(state); console.log(JSON.stringify({})); return 0; } catch (error) { console.error("[continuous-learning-stop] failed", error); console.log(JSON.stringify({})); return 0; }}
const exitCode = await main(process.argv.slice(2));process.exit(exitCode);