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

181 lines (180 loc) • 7.73 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 { openDatabase } from "../../../storage/database.js"; import { openSqliteReadSnapshot } from "../../../storage/sqlite-read-snapshot.js"; import { extractInlineRefMentions } from "../../session-logs/inline-refs.js"; import { AbstractSessionLogProvider } from "../../session-logs/provider-base.js"; function getOpenCodeBaseDir() { if (process.platform === "darwin") { return path.join(os.homedir(), "Library", "Application Support", "opencode"); } return path.join(os.homedir(), ".local", "share", "opencode"); } /** * Opencode session storage: * * SQLite (current, observed 2026-06): `<base>/opencode.db` — a Drizzle-managed * database with `session` / `message` / `part` tables. Message text lives in * `part` rows (`data` JSON, `type: "text"`); `message.data` holds role/timing. * This is the sole supported layout. */ /** Filename of opencode's SQLite session store, relative to its base dir. */ const OPENCODE_DB_FILENAME = "opencode.db"; export class OpenCodeProvider extends AbstractSessionLogProvider { name = "opencode"; #baseDir = getOpenCodeBaseDir(); availabilityRoot() { return this.#dbPath(this.#baseDir); } /** Absolute path to opencode's SQLite store under `base`. */ #dbPath(base) { return path.join(base, OPENCODE_DB_FILENAME); } listSessions(input = {}) { const base = input.location ?? this.#baseDir; const sinceMs = input.sinceMs ?? 0; const dbPath = this.#dbPath(base); if (!fs.existsSync(dbPath)) return []; return this.#listSessionsFromDb(dbPath, sinceMs, input.isolatedSnapshot ?? false); } readSession(ref) { return this.#readSessionFromDb(ref); } /** * List sessions from the SQLite store. `filePath` on each summary is the * `opencode.db` path so {@link readSession} can route back to the DB reader. * Returns `[]` (never throws) when the DB is unreadable or lacks the expected * schema — callers treat a missing harness as "no sessions". */ #listSessionsFromDb(dbPath, sinceMs, isolatedSnapshot) { let db; try { const opened = isolatedSnapshot ? openSqliteReadSnapshot(dbPath) : openDatabase(dbPath, { readonly: true, create: false }); if (!opened) return []; db = opened; } catch { return []; } try { const rows = db .prepare("SELECT id, title, directory, time_created, time_updated FROM session WHERE time_updated >= ? ORDER BY time_updated DESC") .all(sinceMs); return rows.map((r) => this.sessionRef({ sessionId: r.id, filePath: dbPath, startedAt: typeof r.time_created === "number" ? r.time_created : undefined, endedAt: typeof r.time_updated === "number" ? r.time_updated : undefined, projectHint: typeof r.directory === "string" && r.directory.length > 0 ? r.directory : undefined, title: typeof r.title === "string" && r.title.length > 0 ? r.title : undefined, })); } catch { // Missing `session` table / unexpected schema — treat as no sessions. return []; } finally { db.close(); } } /** * Read one session from the SQLite store. Message text lives in `part` rows * (`type: "text"`); `message.data` carries role + timing. One event per * message, text-parts concatenated in time order. Returns empty events * (never throws) when the DB is unreadable. */ #readSessionFromDb(ref) { const emptyRef = this.sessionRef({ sessionId: ref.sessionId, filePath: ref.filePath }); let db; try { db = openDatabase(ref.filePath, { readonly: true, create: false }); } catch { return { ref: emptyRef, events: [], inlineRefs: [] }; } try { const meta = db .prepare("SELECT title, directory, time_created, time_updated FROM session WHERE id = ?") .get(ref.sessionId); const startedAt = typeof meta?.time_created === "number" ? meta.time_created : undefined; const endedAt = typeof meta?.time_updated === "number" ? meta.time_updated : undefined; const title = typeof meta?.title === "string" && meta.title.length > 0 ? meta.title : undefined; const projectHint = typeof meta?.directory === "string" && meta.directory.length > 0 ? meta.directory : undefined; const messages = db .prepare("SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC") .all(ref.sessionId); const parts = db .prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC") .all(ref.sessionId); // Group text-part bodies by their parent message. const textByMessage = new Map(); for (const part of parts) { let parsed; try { parsed = JSON.parse(part.data); } catch { continue; } if (parsed?.type !== "text") continue; const text = parsed.text; if (typeof text !== "string" || text.length < 1) continue; const bucket = textByMessage.get(part.message_id) ?? []; bucket.push(text); textByMessage.set(part.message_id, bucket); } const events = []; const inlineRefs = []; for (const message of messages) { let mdata = {}; try { mdata = JSON.parse(message.data); } catch { // role/timing unavailable — fall through with defaults } const role = typeof mdata.role === "string" ? mdata.role : "unknown"; const mtime = mdata.time?.created; const ts = typeof mtime === "number" ? mtime : typeof message.time_created === "number" ? message.time_created : undefined; const text = (textByMessage.get(message.id) ?? []).join("\n").trim(); if (text.length < 1) continue; events.push({ harness: this.name, text, ts, sessionId: ref.sessionId, role, filePath: ref.filePath }); inlineRefs.push(...extractInlineRefMentions(text, ts)); } events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0)); return { ref: this.sessionRef({ sessionId: ref.sessionId, filePath: ref.filePath, startedAt, endedAt, projectHint, title, }), events, inlineRefs, }; } catch { return { ref: emptyRef, events: [], inlineRefs: [] }; } finally { db.close(); } } }