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

182 lines (181 loc) • 8.54 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/. /** * Session assets (#561): besides its memory proposals, extract writes each * session to `sessions/<harness>/<session-id>.md` as a searchable `session` * asset. Additive and fail-open — no summary means nothing is written — and * `log_path` + `access` in the frontmatter tell any agent how to read the raw log. */ import fs from "node:fs"; import path from "node:path"; import { stashDirFor } from "../../core/asset/asset-placement.js"; import { assembleAsset } from "../../core/asset/asset-serialize.js"; import { conceptIdFromTypeName } from "../../core/asset/resolve-ref.js"; import { parseEmbeddedJsonResponse } from "../../core/parse.js"; import { recordWrittenPath } from "../../core/write-provenance.js"; export const SESSION_SUMMARY_JSON_SCHEMA = { type: "object", required: ["summary", "key_topics"], additionalProperties: false, properties: { summary: { type: "string" }, key_topics: { type: "array", items: { type: "string" } }, tags: { type: "array", items: { type: "string" } }, }, }; /** The transcript for the summary prompt, capped at `maxChars`. */ function renderTranscriptForSummary(events, maxChars = 12_000) { if (events.length === 0) return "(empty — no events)"; const lines = []; let total = 0; for (const e of events) { const role = e.role ?? "unknown"; const text = e.text.trim(); if (!text) continue; const line = `[${role}] ${text}`; if (total + line.length > maxChars) break; lines.push(line); total += line.length + 2; } return lines.join("\n\n") || "(empty — no textual events)"; } export function buildSessionSummaryPrompt(data) { const ref = data.ref; const startedAt = isoOrUndefined(ref.startedAt) ?? "unknown"; const endedAt = isoOrUndefined(ref.endedAt) ?? "unknown"; return [ "You are summarizing an agent coding session so it can be found later via semantic search.", "Write a DENSE 2–4 sentence summary of what was worked on, the key decisions made, and the outcomes.", "Then list the concrete entities touched: files, GitHub issues/PRs, commands, concepts, and people.", "Optimise the summary for recall — include the specific nouns an agent would search for.", "", `Harness: ${ref.harness}`, `Project: ${ref.projectHint ?? "(unknown)"}`, `Started: ${startedAt} Ended: ${endedAt}`, `Title: ${ref.title ?? "(none)"}`, "", "Transcript:", renderTranscriptForSummary(data.events), "", 'Respond as JSON: {"summary": string, "key_topics": string[], "tags"?: string[]}.', ].join("\n"); } /** The summary JSON, tolerating prose around it; `undefined` when nothing usable parses. */ export function parseSessionSummary(raw) { if (!raw || raw.trim().length === 0) return undefined; const obj = parseEmbeddedJsonResponse(raw); if (!obj || typeof obj !== "object" || Array.isArray(obj)) return undefined; const summary = typeof obj.summary === "string" ? obj.summary.trim() : ""; if (summary.length === 0) return undefined; const keyTopics = Array.isArray(obj.key_topics) ? obj.key_topics.filter((t) => typeof t === "string" && t.trim().length > 0) : []; const tags = Array.isArray(obj.tags) ? obj.tags.filter((t) => typeof t === "string" && t.trim().length > 0) : undefined; return { summary, keyTopics, ...(tags && tags.length > 0 ? { tags } : {}) }; } /** Long enough to index (`<= 0` disables; a missing timestamp is no evidence of a trivial session). */ export function sessionMeetsDurationGate(data, minDurationMinutes) { if (!Number.isFinite(minDurationMinutes) || minDurationMinutes <= 0) return true; const { startedAt, endedAt } = data.ref; if (typeof startedAt !== "number" || typeof endedAt !== "number") return true; return (endedAt - startedAt) / 60_000 >= minDurationMinutes; } /** How an agent reads and parses the raw log at `log_path`, per harness (`cat` otherwise). */ export function buildSessionAccessInstructions(harness, logPath, sessionId) { if (harness === "claude") { return [ `Read with: cat ${logPath}`, `Parse messages: jq -r 'select(.type=="message") | .message.content[]? | select(.type=="text") | .text' ${logPath}`, ].join("\n"); } if (harness === "codex") { return [ `Read with: cat ${logPath}`, `Parse messages: jq -r 'select(.type=="response_item" and .payload.type=="message") | .payload.content[]? | .text // empty' ${logPath}`, ].join("\n"); } if (harness === "opencode") { return [ `Open the SQLite database at ${JSON.stringify(logPath)} in read-only mode.`, "Query: SELECT m.data, p.data FROM message AS m JOIN part AS p ON p.message_id = m.id WHERE m.session_id = ? AND p.session_id = ? ORDER BY m.time_created, p.time_created;", `Bind both parameters to ${JSON.stringify(sessionId)}.`, ].join("\n"); } return `Read with: cat ${logPath}`; } function isoOrUndefined(ms) { return typeof ms === "number" && Number.isFinite(ms) ? new Date(ms).toISOString() : undefined; } /** Default session-name slug: `<harness>-session-<yyyy-mm-dd>-<shortId>`. */ export function buildSessionAssetName(harness, sessionId, startedAtMs) { return `${harness}-session-${isoOrUndefined(startedAtMs)?.slice(0, 10) ?? "unknown-date"}-${sessionId.slice(0, 8)}`; } /** The session asset: frontmatter plus `## Summary` and `## Key topics`. */ export function buildSessionAssetContent(data, summary) { const ref = data.ref; const harness = ref.harness; const startedAt = isoOrUndefined(ref.startedAt); const endedAt = isoOrUndefined(ref.endedAt); const name = buildSessionAssetName(harness, ref.sessionId, ref.startedAt); const logPath = ref.filePath; const baseTags = ["session", harness]; const extraTags = (summary.tags ?? []).filter((t) => typeof t === "string" && t.trim().length > 0); const tags = Array.from(new Set([...baseTags, ...extraTags])); const frontmatter = { name, type: "session", harness, session_id: ref.sessionId, ...(startedAt ? { started_at: startedAt } : {}), ...(endedAt ? { ended_at: endedAt } : {}), ...(ref.projectHint ? { project: ref.projectHint } : {}), log_path: logPath, access: buildSessionAccessInstructions(harness, logPath, ref.sessionId), tags, }; const topics = summary.keyTopics .filter((t) => typeof t === "string" && t.trim().length > 0) .map((t) => `- ${t.trim()}`) .join("\n"); const body = `## Summary\n\n${summary.summary.trim()}\n\n## Key topics\n\n${topics || "- (none extracted)"}\n`; // The summary doubles as the description, as for other types. const content = assembleAsset({ ...frontmatter, description: summary.summary.trim() }, body); return { name, frontmatter, content }; } /** Resolve `<stash>/sessions/<harness>/<session-id>.md`. */ export function resolveSessionAssetPath(stashDir, harness, sessionId) { const dir = stashDirFor("session") ?? "sessions"; return path.join(stashDir, dir, harness, `${sessionId}.md`); } /** Summarize and write a session asset; nothing without a summary. The caller swallows write errors. */ export async function writeSessionAsset(data, stashDir, generate) { const summary = await generate(data); if (!summary?.summary || summary.summary.trim().length === 0) { return { written: false }; } const { content } = buildSessionAssetContent(data, summary); const harness = data.ref.harness; const sessionId = data.ref.sessionId; const filePath = resolveSessionAssetPath(stashDir, harness, sessionId); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, content, "utf8"); // Written outside the proposal queue: journal it so auto-sync commits it (#652). recordWrittenPath(filePath); return { written: true, filePath, ref: conceptIdFromTypeName("session", `${harness}/${sessionId}`), logPath: data.ref.filePath, }; }