UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

281 lines (280 loc) • 12.2 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Prompt + schema for `akm extract <session>`. * * Mirrors the REFLECT_JSON_SCHEMA pattern: a strict JSON Schema describing * the LLM output, plus a {@link buildExtractPrompt} helper that interpolates * session data into the markdown template loaded from * `src/assets/prompts/extract-session.md`. * * The schema is intentionally strict — a provider that honours * `response_format` enforces shape upstream, so the parser only has to handle * the happy path. `additionalProperties: false` means any hallucinated keys * the model emits get dropped before we parse. */ import promptTemplate from "../../assets/prompts/extract-session.md" with { type: "text" }; import { escapeJsonStringControls, stripCodeFences, stripThinkBlocks } from "../../core/parse.js"; const EXTRACT_CANDIDATE_NAME_PATTERN = "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)?$"; const EXTRACT_CANDIDATE_NAME_RE = new RegExp(EXTRACT_CANDIDATE_NAME_PATTERN); /** * JSON Schema for the structured extract output. Passed to `chatCompletion` * unless the configured LLM connection sets `supportsJsonSchema: false`. * * Shape: * { * "candidates": [{type, name, description, when_to_use?, body, confidence, evidence}, ...], * "rationale_if_empty"?: string * } * * `additionalProperties: false` at each level so any hallucinated keys are * dropped before parsing. */ export const EXTRACT_JSON_SCHEMA = { type: "object", required: ["candidates"], additionalProperties: false, properties: { candidates: { type: "array", description: "Zero or more durable-insight candidates extracted from the session.", items: { type: "object", required: ["type", "name", "description", "body", "confidence", "evidence"], additionalProperties: false, properties: { type: { type: "string", enum: ["memory", "lesson", "knowledge"], description: "Asset type the candidate would land as.", }, name: { type: "string", description: "Kebab-case slug, optionally under one stable scope/domain segment.", pattern: EXTRACT_CANDIDATE_NAME_PATTERN, }, description: { type: "string", minLength: 20, maxLength: 400, description: "One-sentence summary of the candidate. Must be a complete sentence in active voice. Do NOT start with 'When', 'If', 'How', 'Use', or 'Avoid'. Do NOT end with ':', ';', or ','. Do NOT use heading-fragment text ('Summary', 'Overview', 'Key finding:'). Minimum 20 characters, maximum 400 characters.", }, when_to_use: { type: "string", minLength: 15, maxLength: 400, description: "Trigger sentence for the candidate; REQUIRED when type=lesson.", }, body: { type: "string", minLength: 50, description: "Markdown body of the candidate asset.", }, confidence: { type: "number", minimum: 0, maximum: 1, description: "Self-rated confidence in [0, 1] that this candidate is a real durable insight.", }, evidence: { type: "string", minLength: 5, description: "One-line pointer to the moment in the session that supports this candidate.", }, }, }, }, rationale_if_empty: { type: "string", minLength: 10, description: "Required when `candidates` is empty — explains why nothing rose to durable-insight level.", }, }, }; /** * Format inline refs as a bullet list for the "Already preserved" section. * If empty, returns a sentinel string so the LLM knows the agent saved * nothing inline. */ function formatAlreadyPreserved(inlineRefs) { if (inlineRefs.length === 0) { return "(none — the agent did not call `akm remember` or `akm feedback` during this session)"; } return inlineRefs .map((ref) => { const prefix = ref.kind === "remember" ? "- remember:" : `- feedback ${ref.ref ?? "<ref>"}:`; const body = ref.text.trim().slice(0, 200); return `${prefix} ${body}${ref.text.length > 200 ? "…" : ""}`; }) .join("\n"); } /** * Delimiters that fence the untrusted session transcript in the extract prompt. * Everything between the markers is DATA to analyze, never instructions to obey. The * transcript is external, attacker-influenceable content, so an explicit, * greppable boundary defuses prompt-injection that tries to pose as a command. */ export const TRANSCRIPT_FENCE_BEGIN = "=== BEGIN UNTRUSTED SESSION TRANSCRIPT ==="; export const TRANSCRIPT_FENCE_END = "=== END UNTRUSTED SESSION TRANSCRIPT ==="; /** * Format pre-filtered events as a transcript snippet. Each event becomes: * [<role> @ <iso>] <text> * Events are already truncated/cleaned by the pre-filter; this is purely * a render step. * * Anti-spoof: any occurrence of the fence markers inside the transcript text is * neutralised so a crafted session cannot forge the boundary and "escape" the * fence to inject trusted-looking instructions. */ function formatTranscript(events) { if (events.length === 0) return "(empty — pre-filter removed all events as noise)"; const body = events .map((e) => { const tsLabel = e.ts ? new Date(e.ts).toISOString() : "unknown-ts"; const roleLabel = e.role ?? "unknown"; return `[${roleLabel} @ ${tsLabel}] ${e.text}`; }) .join("\n\n"); return body.split(TRANSCRIPT_FENCE_BEGIN).join("=== (fence) ===").split(TRANSCRIPT_FENCE_END).join("=== (fence) ==="); } /** * Build the user-prompt body for the extract LLM call by interpolating * session metadata, already-preserved refs, and the filtered transcript * into the template. */ export function buildExtractPrompt(input) { const ref = input.data.ref; const startedAt = ref.startedAt ? new Date(ref.startedAt).toISOString() : "unknown"; const endedAt = ref.endedAt ? new Date(ref.endedAt).toISOString() : "unknown"; // Optional standards block — rendered to the lead-in + body when present, // or an empty string (no section) when absent. Gated on non-empty. const standards = input.standardsContext?.trim() ? `\n## Standards to follow (the rulebook for this target)\n\n${input.standardsContext.trim()}\n` : ""; return promptTemplate .replace("{{HARNESS}}", ref.harness) .replace("{{TITLE}}", ref.title ?? "(no title)") .replace("{{STARTED_AT}}", startedAt) .replace("{{ENDED_AT}}", endedAt) .replace("{{PROJECT_HINT}}", ref.projectHint ?? "(no project hint)") .replace("{{ALREADY_PRESERVED}}", formatAlreadyPreserved(input.inlineRefs)) .replace("{{STANDARDS}}", standards) .replace("{{TRANSCRIPT}}", `${TRANSCRIPT_FENCE_BEGIN}\n${formatTranscript(input.events)}\n${TRANSCRIPT_FENCE_END}`); } function failedExtractPayload(code, message) { return { candidates: [], rationale_if_empty: message, parseFailure: { code, message } }; } function parseFirstJsonObject(stdout) { const text = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(stdout))); try { return { objectFound: text.startsWith("{"), value: JSON.parse(text) }; } catch { // Continue with the first object embedded in prose. Once an opening brace // is found, never descend into a nested object if that outer object is // malformed or truncated: doing so can turn a broken candidate into a // superficially valid top-level payload. } const start = text.indexOf("{"); if (start < 0) return { objectFound: false }; let depth = 0; let inString = false; let escaped = false; for (let index = start; index < text.length; index++) { const char = text[index]; if (inString) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === '"') inString = false; continue; } if (char === '"') { inString = true; continue; } if (char === "{") depth += 1; else if (char === "}") { depth -= 1; if (depth !== 0) continue; try { return { objectFound: true, value: JSON.parse(text.slice(start, index + 1)) }; } catch { return { objectFound: true }; } } } return { objectFound: true }; } /** * Parse the LLM's JSON response into a structured {@link ExtractPayload}. * Defensive — drops candidates that violate the shape rather than failing * the whole call. Returns the empty-candidates payload when nothing parses. */ export function parseExtractPayload(stdout) { if (!stdout || stdout.trim().length === 0) { return failedExtractPayload("empty_response", "LLM returned an empty response"); } const parsed = parseFirstJsonObject(stdout); if (parsed.value === undefined) { return parsed.objectFound ? failedExtractPayload("invalid_json_object", "JSON object was found but could not be parsed") : failedExtractPayload("no_json_object", "LLM response: no JSON object found"); } if (!parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) { return failedExtractPayload("invalid_payload", "LLM response JSON was not an object"); } const obj = parsed.value; if (!Array.isArray(obj.candidates)) { return failedExtractPayload("invalid_payload", "LLM response JSON did not contain a candidates array"); } const rawCandidates = obj.candidates; const candidates = []; for (const raw of rawCandidates) { if (!raw || typeof raw !== "object") continue; const c = raw; const type = c.type; if (type !== "memory" && type !== "lesson" && type !== "knowledge") continue; if (typeof c.name !== "string" || !EXTRACT_CANDIDATE_NAME_RE.test(c.name)) continue; if (typeof c.description !== "string" || c.description.trim().length < 20) continue; if (typeof c.body !== "string" || c.body.trim().length < 50) continue; if (typeof c.confidence !== "number" || !Number.isFinite(c.confidence)) continue; if (typeof c.evidence !== "string" || c.evidence.trim().length < 5) continue; if (type === "lesson") { if (typeof c.when_to_use !== "string" || c.when_to_use.trim().length < 15) continue; } const confidence = Math.max(0, Math.min(1, c.confidence)); const candidate = { type, name: c.name, description: c.description.trim(), body: c.body, confidence, evidence: c.evidence.trim(), }; if (typeof c.when_to_use === "string") candidate.when_to_use = c.when_to_use.trim(); candidates.push(candidate); } const result = { candidates }; if (typeof obj.rationale_if_empty === "string") { result.rationale_if_empty = obj.rationale_if_empty.trim(); } return result; }