import { createHash, randomBytes } from "node:crypto"; import { Type } from "typebox"; export const JOB_VERSION = 2 as const; export const RECORD_VERSION = 2 as const; export const FAILURE_VERSION = 1 as const; export type FeedbackKind = "praise" | "problem"; export interface ModelRef { provider: string; id: string; } export interface SessionLearningSource { type: "session"; cwd: string; sessionPath: string | null; sessionId: string; sessionName: string | null; leafId: string | null; agentModel: ModelRef | null; } export interface ImportedLearningSource { type: "import"; importer: string; fingerprint: string; importedAt: string; } export type LearningSource = SessionLearningSource | ImportedLearningSource; export interface GoodJobJob { version: typeof JOB_VERSION; id: string; createdAt: string; kind: FeedbackKind; feedback: string; source: SessionLearningSource; analysisModel: ModelRef | null; } export interface Learning { slug: string; summary: string; evidence: string[]; behaviors: string[]; candidateRule: string | null; } export interface TokenUsage { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; cost: number; } export interface GoodJobRecord { version: typeof RECORD_VERSION; id: string; createdAt: string; completedAt: string; kind: FeedbackKind; feedback: string; source: LearningSource; analysisModel: ModelRef | null; learning: Learning; usage: TokenUsage | null; } export interface GoodJobFailure { version: typeof FAILURE_VERSION; job: GoodJobJob; failedAt: string; error: string; } const CONTROL_CHARACTERS = /[\x00-\x1F\x7F-\x9F]/g; const CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz"; const GOOD_JOB_TRIGGER = /^(?:gj|good\s+job)[.!?]*(?:\s+(.*))?$/is; const WTF_TRIGGER = /^wtf[.!?]*(?:\s+(.*))?$/is; const MAX_SUMMARY_CHARS = 180; const MAX_ITEM_CHARS = 500; const MAX_RULE_CHARS = 700; const MAX_ITEMS = 8; const MAX_SLUG_CHARS = 48; export const LearningSubmissionSchema = Type.Object( { slug: Type.String({ maxLength: MAX_SLUG_CHARS, pattern: "^[a-z0-9]+(?:-[a-z0-9]+){1,5}$", description: "Memorable kebab-case label using 2-6 short words.", }), summary: Type.String({ minLength: 1, maxLength: MAX_SUMMARY_CHARS, description: "One concrete summary.", }), evidence: Type.Array( Type.String({ minLength: 1, maxLength: MAX_ITEM_CHARS }), { minItems: 1, maxItems: MAX_ITEMS, description: "Observable transcript facts or user-visible outcomes.", }, ), behaviors: Type.Array( Type.String({ minLength: 1, maxLength: MAX_ITEM_CHARS }), { minItems: 1, maxItems: MAX_ITEMS, description: "Specific agent behaviors that succeeded, failed, or should change.", }, ), candidate_rule: Type.Union( [Type.String({ minLength: 1, maxLength: MAX_RULE_CHARS }), Type.Null()], { description: "Concise reusable instruction, or null when evidence is insufficient.", }, ), }, { additionalProperties: false }, ); export function feedbackTrigger( text: string, ): { kind: FeedbackKind; feedback: string } | undefined { const trimmed = text.trim(); const problem = WTF_TRIGGER.exec(trimmed); if (problem) return { kind: "problem", feedback: problem[1]?.trim() || "wtf", }; const praise = GOOD_JOB_TRIGGER.exec(trimmed); if (praise) return { kind: "praise", feedback: praise[1]?.trim() || trimmed, }; return undefined; } export function cleanText(text: string, maximum: number): string { const cleaned = text .replace(CONTROL_CHARACTERS, " ") .replace(/\s+/g, " ") .trim(); return Array.from(cleaned).slice(0, maximum).join(""); } export function learningSlug(text: string): string { const slug = text .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .split("-") .filter(Boolean) .slice(0, 6) .join("-"); return slug.slice(0, MAX_SLUG_CHARS).replace(/-+$/g, "") || "learning"; } function encodeCrockford(bytes: Uint8Array): string { let value = 0n; for (const byte of bytes) value = (value << 8n) | BigInt(byte); value &= (1n << 50n) - 1n; let encoded = ""; for (let index = 0; index < 10; index++) { encoded = CROCKFORD[Number(value & 31n)] + encoded; value >>= 5n; } return encoded; } export function feedbackId( kind: FeedbackKind, bytes: Uint8Array = randomBytes(7), ): string { return `${kind === "praise" ? "gj" : "wtf"}-${encodeCrockford(bytes)}`; } export function feedbackIdFromSeed(kind: FeedbackKind, seed: string): string { return feedbackId( kind, createHash("sha256").update(seed).digest().subarray(0, 7), ); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function cleanStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const strings = value .slice(0, MAX_ITEMS) .map((item) => typeof item === "string" ? cleanText(item, MAX_ITEM_CHARS) : "", ) .filter(Boolean); return strings.length > 0 ? strings : undefined; } function jsonObject(text: string): unknown { const trimmed = text.trim(); const unfenced = trimmed .replace(/^```(?:json)?\s*/i, "") .replace(/\s*```$/, "") .trim(); const start = unfenced.indexOf("{"); const end = unfenced.lastIndexOf("}"); if (start < 0 || end < start) throw new Error("analysis did not return JSON"); return JSON.parse(unfenced.slice(start, end + 1)); } export function parseLearning(text: string): Learning { const value = jsonObject(text); if (!isRecord(value)) throw new Error("analysis returned a non-object"); const summary = typeof value.summary === "string" ? cleanText(value.summary, MAX_SUMMARY_CHARS) : ""; const slug = typeof value.slug === "string" ? learningSlug(value.slug) : learningSlug(summary); const evidence = cleanStringArray(value.evidence); const behaviors = cleanStringArray(value.behaviors); const candidateValue = value.candidate_rule ?? value.candidateRule; const candidateRule = candidateValue === null ? null : typeof candidateValue === "string" ? cleanText(candidateValue, MAX_RULE_CHARS) : undefined; if (!summary || !evidence || !behaviors) throw new Error("analysis JSON is missing required learning fields"); if (candidateRule === undefined) throw new Error("analysis JSON is missing candidate_rule"); return { slug, summary, evidence, behaviors, candidateRule }; } export function analysisPrompt(kind: FeedbackKind, feedback: string): string { const task = kind === "praise" ? "Identify what concretely went well and which agent behaviors caused it." : "Identify what surprised, confused, or annoyed the user and which behavior should prevent recurrence."; return `The user supplied ${kind} feedback: ${JSON.stringify(feedback)} Analyze the conversation preceding that feedback. ${task} Infer causes only when supported by the transcript. Focus on reusable agent behavior, not compliments, blame, or personality. Do not reproduce credentials, secrets, long source excerpts, or private data. Call submit_learning exactly once with these fields: - slug: memorable kebab-case label using 2-6 short words - summary: one concrete summary, at most 180 characters - evidence: 1-8 observable transcript facts or user-visible outcomes - behaviors: 1-8 specific agent behaviors that succeeded, failed, or should change - candidate_rule: concise reusable instruction, or null when evidence is insufficient Output no prose.`; } export function lastAgentModel(messages: readonly unknown[]): ModelRef | null { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (!isRecord(message) || message.role !== "assistant") continue; if ( typeof message.provider !== "string" || typeof message.model !== "string" ) continue; return { provider: message.provider, id: message.model }; } return null; } export function boundedError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return cleanText(message, 300) || "unknown error"; }