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
JavaScript
// 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,
};
}