{"version":3,"file":"triage.d.ts","sourceRoot":"","sources":["../../src/evolve/triage.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAI7D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAG5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAsB9D,MAAM,WAAW,gBAAgB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACpC,gFAAgF;IAChF,GAAG,EAAE,OAAO,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,uBAAuB;IACvC,KAAK,EAAE,QAAQ,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CACjB;AAmCD,MAAM,WAAW,mBAAmB;IACnC,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,qEAAqE;AACrE,wBAAsB,sBAAsB,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAK1F;AAuBD;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAoEtG","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { join } from \"node:path\";\nimport type { ThinkingLevel } from \"@ch1nyzzz/pi-agent-core\";\nimport { StringEnum } from \"@ch1nyzzz/pi-ai\";\nimport { Type } from \"typebox\";\nimport { initializeInboxLifecycle } from \"../inbox.ts\";\nimport type { EvoPaths } from \"../paths.ts\";\nimport { listSessionDigests, type SessionDigest } from \"../recorder/digest.ts\";\nimport type { RecorderInboxEntry } from \"../recorder/schema.ts\";\nimport type { ModelRunner } from \"../reflect/model-runner.ts\";\nimport { recordModelUsage } from \"../reflect/usage.ts\";\nimport { atomicWriteJson, readJsonIfExists } from \"../storage.ts\";\n\nconst TRIAGE_CURSOR_FILE = \"triage-cursor.json\";\nconst MAX_HYPOTHESES = 5;\nconst MAX_DIGEST_LINES = 40;\n\nconst TRIAGE_SYSTEM_PROMPT = [\n\t\"You are the Evo-Pi session triage scout: the cheapest, earliest stage of the harness self-improvement loop.\",\n\t\"You read compact per-session telemetry digests and flag directions worth a deeper evolution investigation.\",\n\t\"Propose a hypothesis only when the telemetry shows real friction (tool errors, failed verification,\",\n\t\"user follow-up corrections, repeated compaction pressure). Fewer, sharper hypotheses beat many vague ones.\",\n\t\"Propose nothing when the sessions look healthy.\",\n].join(\" \");\n\ninterface TriageCursor {\n\tschemaVersion: 1;\n\tlastSessionKey: string;\n\tlastRunAt: string;\n}\n\nexport interface TriageHypothesis {\n\tdirection: string;\n\tsummary: string;\n\tevidenceSessions: string[];\n}\n\nexport interface SessionTriageOutcome {\n\t/** False when fewer than `everyNSessions` new complete sessions accumulated. */\n\tran: boolean;\n\tnewSessions: number;\n\thypotheses: TriageHypothesis[];\n\tinboxFiles: string[];\n}\n\nexport interface RunSessionTriageOptions {\n\tpaths: EvoPaths;\n\trunner: ModelRunner;\n\tmodel: string;\n\tthinkingLevel?: ThinkingLevel;\n\teveryNSessions: number;\n\tcwd?: string;\n\tagentDir?: string;\n\tsignal?: AbortSignal;\n\tnow?: () => Date;\n}\n\nfunction digestKey(digest: SessionDigest): string {\n\treturn `${digest.startedAt ?? \"\"}|${digest.sessionId}`;\n}\n\nfunction digestLine(digest: SessionDigest): string {\n\tconst metrics = digest.metrics;\n\treturn [\n\t\t`- session ${digest.sessionId} (${digest.taskClass})`,\n\t\t`tasks=${metrics.tasks}`,\n\t\t`toolCalls=${metrics.toolCalls}`,\n\t\t`toolErrors=${metrics.toolErrors}`,\n\t\t`verification=${digest.assessment.verification}`,\n\t\t`followUps=${metrics.followUpUserMessages}`,\n\t\t`preferenceSignals=${metrics.preferenceSignals}`,\n\t\t`compactions=${metrics.compactions}`,\n\t\t`models=${digest.models.join(\"+\") || \"none\"}`,\n\t].join(\" \");\n}\n\nasync function readTriageCursor(paths: EvoPaths): Promise<TriageCursor | undefined> {\n\tconst raw = await readJsonIfExists(join(paths.root, TRIAGE_CURSOR_FILE));\n\tif (typeof raw !== \"object\" || raw === null) return undefined;\n\tconst cursor = raw as Record<string, unknown>;\n\tif (\n\t\tcursor.schemaVersion !== 1 ||\n\t\ttypeof cursor.lastSessionKey !== \"string\" ||\n\t\ttypeof cursor.lastRunAt !== \"string\"\n\t) {\n\t\treturn undefined;\n\t}\n\treturn cursor as unknown as TriageCursor;\n}\n\nexport interface SessionTriageStatus {\n\t/** ISO timestamp of the last triage run, absent when triage never ran. */\n\tlastRunAt?: string;\n\t/** Complete sessions recorded since the cursor (all sessions when no cursor). */\n\tnewSessions: number;\n}\n\n/** Read-only view of the triage cursor and the backlog behind it. */\nexport async function getSessionTriageStatus(paths: EvoPaths): Promise<SessionTriageStatus> {\n\tconst cursor = await readTriageCursor(paths);\n\tconst digests = (await listSessionDigests(paths)).filter((digest) => digest.complete);\n\tconst fresh = cursor ? digests.filter((digest) => digestKey(digest) > cursor.lastSessionKey) : digests;\n\treturn { ...(cursor ? { lastRunAt: cursor.lastRunAt } : {}), newSessions: fresh.length };\n}\n\nconst TRIAGE_SUBMISSION_PARAMETERS = Type.Object(\n\t{\n\t\thypotheses: Type.Array(\n\t\t\tType.Object(\n\t\t\t\t{\n\t\t\t\t\tdirection: Type.String({\n\t\t\t\t\t\tpattern: \"^[a-z0-9][a-z0-9-]{2,63}$\",\n\t\t\t\t\t\tdescription: \"Kebab-case improvement direction slug\",\n\t\t\t\t\t}),\n\t\t\t\t\tsummary: Type.String({ minLength: 8, maxLength: 500 }),\n\t\t\t\t\tevidenceSessions: Type.Array(Type.String(), { maxItems: 8 }),\n\t\t\t\t\tsuggestedKind: StringEnum([\"data\", \"component\", \"workflow\", \"code\"]),\n\t\t\t\t},\n\t\t\t\t{ additionalProperties: false },\n\t\t\t),\n\t\t\t{ maxItems: MAX_HYPOTHESES },\n\t\t),\n\t},\n\t{ additionalProperties: false },\n);\n\n/**\n * The streaming-triage stage: every N completed sessions, a minimum-cost model\n * scans the new session digests and files structured improvement hypotheses\n * into the inbox, where the (expensive) research phase later consumes them as\n * pre-triaged evidence. Never advances the evolution evidence cursor.\n */\nexport async function runSessionTriage(options: RunSessionTriageOptions): Promise<SessionTriageOutcome> {\n\tif (!Number.isSafeInteger(options.everyNSessions) || options.everyNSessions < 1) {\n\t\tthrow new Error(\"triage everyNSessions must be a positive integer\");\n\t}\n\tconst now = options.now ?? (() => new Date());\n\tconst cursor = await readTriageCursor(options.paths);\n\tconst digests = (await listSessionDigests(options.paths)).filter((digest) => digest.complete);\n\tconst fresh = cursor ? digests.filter((digest) => digestKey(digest) > cursor.lastSessionKey) : digests;\n\tif (fresh.length < options.everyNSessions) {\n\t\treturn { ran: false, newSessions: fresh.length, hypotheses: [], inboxFiles: [] };\n\t}\n\tconst window = fresh.slice(-MAX_DIGEST_LINES);\n\tconst prompt = [\n\t\t`Telemetry digests for ${window.length} recent sessions:`,\n\t\t\"\",\n\t\t...window.map(digestLine),\n\t\t\"\",\n\t\t`Flag at most ${MAX_HYPOTHESES} improvement hypotheses worth a deeper evolution run.`,\n\t\t\"Submit them with the triage tool. Submit an empty list when the sessions look healthy.\",\n\t].join(\"\\n\");\n\tconst run = await options.runner.run({\n\t\tcwd: options.cwd ?? process.cwd(),\n\t\t...(options.agentDir ? { agentDir: options.agentDir } : {}),\n\t\tsystemPrompt: TRIAGE_SYSTEM_PROMPT,\n\t\tprompt,\n\t\tmodel: options.model,\n\t\t...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}),\n\t\tsessionIdentity: \"evo-session-triage\",\n\t\tsubmission: {\n\t\t\ttoolName: \"submit_triage\",\n\t\t\tdescription: \"Submit the triage hypotheses extracted from the session digests\",\n\t\t\tparameters: TRIAGE_SUBMISSION_PARAMETERS,\n\t\t},\n\t});\n\tawait recordModelUsage(options.paths, \"triage\", run);\n\tconst submitted =\n\t\ttypeof run.submission === \"object\" && run.submission !== null\n\t\t\t? ((run.submission as { hypotheses?: TriageHypothesis[] }).hypotheses ?? [])\n\t\t\t: [];\n\tconst hypotheses = submitted.slice(0, MAX_HYPOTHESES);\n\tconst timestamp = now().toISOString();\n\tconst sessionId = window.at(-1)?.sessionId ?? \"triage\";\n\tconst inboxFiles: string[] = [];\n\tfor (const hypothesis of hypotheses) {\n\t\tconst id = randomUUID();\n\t\tconst entry: RecorderInboxEntry = {\n\t\t\tschemaVersion: 1,\n\t\t\tid,\n\t\t\ttimestamp,\n\t\t\tsessionId,\n\t\t\tsource: \"extension\",\n\t\t\tkind: \"note\",\n\t\t\ttext: `NOTE: triage hypothesis (${hypothesis.direction}): ${hypothesis.summary} [evidence sessions: ${hypothesis.evidenceSessions.join(\", \") || \"n/a\"}]`,\n\t\t};\n\t\tconst fileName = `${timestamp.replaceAll(\":\", \"-\")}-${id}.json`;\n\t\tawait atomicWriteJson(join(options.paths.inbox, fileName), entry);\n\t\t// Without an initialized lifecycle the next research run would refuse to\n\t\t// proceed over an \"unclassified\" inbox file; triage notes are pre-classified.\n\t\tawait initializeInboxLifecycle(options.paths, fileName);\n\t\tinboxFiles.push(fileName);\n\t}\n\tconst lastKey = digestKey(fresh[fresh.length - 1] as SessionDigest);\n\tawait atomicWriteJson(join(options.paths.root, TRIAGE_CURSOR_FILE), {\n\t\tschemaVersion: 1,\n\t\tlastSessionKey: lastKey,\n\t\tlastRunAt: timestamp,\n\t} satisfies TriageCursor);\n\treturn { ran: true, newSessions: fresh.length, hypotheses, inboxFiles };\n}\n"]}