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

212 lines (211 loc) • 8.8 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/. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { extractInlineRefMentions } from "../../session-logs/inline-refs.js"; import { AbstractSessionLogProvider } from "../../session-logs/provider-base.js"; /** * Root directory holding Codex's rollout files, one per session, as * `YYYY/MM/DD/rollout-<timestamp>-<thread-id>.jsonl`. * * Resolved per call (not memoized at module load) so `CODEX_HOME` — Codex's own * override of `~/.codex` — can be set after import; tests point it at a fixture * directory instead of the real history. */ function codexSessionsDir() { return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions"); } /** Bytes read to get a rollout's first record, `session_meta`: its embedded base instructions make it 20-50 KB. */ const META_PEEK_BYTES = 128 * 1024; /** * User-role blocks Codex injects itself, which are not the person's words: the * AGENTS.md instructions and the context tags it is known to wrap (environment, * plugin recommendations, an invoked skill's text, ...). Any other block is kept: * a stray bit of context costs less than a dropped reply. */ const INJECTED_CONTEXT_RE = /^\s*(?:# AGENTS\.md instructions[\s\S]*<\/INSTRUCTIONS>|<(environment_context|user_instructions|recommended_plugins|skill|turn_aborted)>[\s\S]*<\/\1>)\s*$/i; /** The `session_meta` record, always a rollout's first; `undefined` for any other record. */ function parseSessionMeta(entry) { const e = entry; if (e?.type !== "session_meta" || !e.payload) return undefined; const { timestamp, cwd, source } = e.payload; const startedAt = typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN; return { ...(Number.isNaN(startedAt) ? {} : { startedAt }), ...(typeof cwd === "string" ? { cwd } : {}), isAgent: typeof source === "object" && source !== null && ("subagent" in source || "internal" in source), }; } /** The text of a message's or tool output's content: a plain string, or blocks whose images and audio carry none. */ function blockText(blocks, keep = () => true) { if (typeof blocks === "string") return blocks; if (!Array.isArray(blocks)) return ""; const parts = []; for (const block of blocks) { const text = block?.text; if (typeof text === "string" && keep(text)) parts.push(text); } return parts.join("\n"); } /** * A tool call's input as text. A shell call surfaces its command line (`cmd`, or * `command` as a string or an argv array) so the inline-ref scanner can match * `akm remember "..."` without JSON-quote escaping mangling the regex; any * other input stays as it was written. */ function toolInputText(input) { let args = input; if (typeof input === "string") { try { args = JSON.parse(input); } catch { return input; } } const fields = args && typeof args === "object" ? args : {}; const cmd = fields.cmd ?? fields.command; if (typeof cmd === "string") return cmd; if (Array.isArray(cmd)) return cmd.join(" "); return typeof input === "string" ? input : (JSON.stringify(input) ?? ""); } /** * Parse one rollout record into a normalized {@link SessionEvent}. The * conversation lives in `response_item` records; the rest (`event_msg` UI events * that repeat it, `turn_context`, `world_state`, token counts, compaction * markers) and items with nothing to read (`reasoning` is encrypted) give * `undefined`. Codex's `developer` messages are its own instructions, so they * are skipped too. * * Tool calls become `[tool:<name>] <input>` and their results `[tool_result] * <output>`, the shapes the Claude reader writes. A result is a `tool` event: * Codex has no enclosing user message to give it. */ function parseCodexRecord(entry, sessionId, filePath, fallbackTsMs) { const e = entry; const item = e?.type === "response_item" ? e.payload : undefined; if (!item) return undefined; const toolName = typeof item.name === "string" ? item.name : "tool"; let role = "assistant"; let text; switch (item.type) { case "message": if (item.role !== "user" && item.role !== "assistant") return undefined; role = item.role; text = blockText(item.content, item.role === "user" ? (t) => !INJECTED_CONTEXT_RE.test(t) : undefined); break; case "function_call": text = `[tool:${toolName}] ${toolInputText(item.arguments)}`; break; case "custom_tool_call": text = `[tool:${toolName}] ${typeof item.input === "string" ? item.input : ""}`; break; case "local_shell_call": text = `[tool:shell] ${toolInputText(item.action)}`; break; case "function_call_output": case "custom_tool_call_output": { role = "tool"; const output = blockText(item.output); text = output ? `[tool_result] ${output}` : ""; break; } default: return undefined; } if (!text.trim()) return undefined; const ts = typeof e?.timestamp === "string" ? Date.parse(e.timestamp) || fallbackTsMs : fallbackTsMs; return { harness: "codex", text, ts, sessionId, role, filePath }; } /** * Codex native session-log reader. * * Events, refs, extraction keys, and the harness registry all use `codex`. The * session id is the thread id in the rollout's file name (the id `codex resume` * takes) and `projectHint` is the session's working directory. */ export class CodexProvider extends AbstractSessionLogProvider { name = "codex"; availabilityRoot() { return codexSessionsDir(); } listSessions(input = {}) { return this.listSessionsFromFiles({ sinceMs: input.sinceMs ?? 0, enumerate: () => this.walkFiles(input.location ?? codexSessionsDir(), (name) => /^rollout-.+\.jsonl$/.test(name)), summarize: (rolloutPath, stat) => { const meta = this.#peekMeta(rolloutPath); if (meta?.isAgent) return undefined; return this.sessionRef({ // The thread id is the 36-character UUID that ends the file name. sessionId: path.basename(rolloutPath, ".jsonl").slice(-36), filePath: rolloutPath, startedAt: meta?.startedAt ?? stat.ctimeMs, endedAt: stat.mtimeMs, projectHint: meta?.cwd, }); }, }); } readSession(ref) { const stat = fs.statSync(ref.filePath); const events = []; const inlineRefs = []; let meta; for (const line of fs.readFileSync(ref.filePath, "utf8").split("\n").filter(Boolean)) { let entry; try { entry = JSON.parse(line); } catch { continue; // a partial line (the session is still being written) or garbage } meta ??= parseSessionMeta(entry); const parsed = parseCodexRecord(entry, ref.sessionId, ref.filePath, stat.mtimeMs); if (!parsed) continue; events.push(parsed); inlineRefs.push(...extractInlineRefMentions(parsed.text, parsed.ts)); } return { ref: this.sessionRef({ sessionId: ref.sessionId, filePath: ref.filePath, startedAt: meta?.startedAt ?? events[0]?.ts ?? stat.ctimeMs, endedAt: events[events.length - 1]?.ts ?? stat.mtimeMs, projectHint: meta?.cwd, }), events, inlineRefs, }; } /** The `session_meta` record on a rollout's first line, read without loading the file. */ #peekMeta(filePath) { try { const fd = fs.openSync(filePath, "r"); try { const head = Buffer.alloc(META_PEEK_BYTES); const firstLine = head.toString("utf8", 0, fs.readSync(fd, head, 0, head.length, 0)).split("\n", 1)[0] ?? ""; return parseSessionMeta(JSON.parse(firstLine)); } finally { fs.closeSync(fd); } } catch { return undefined; // unreadable / vanished file, or a first line that is not one whole record } } }