akm-cli 0.9.0-beta.33 → 0.9.0-beta.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0-beta.34] — 2026-06-21
10
+
11
+ ### Fixed
12
+
13
+ - **`akm extract --type opencode` reads opencode's SQLite session store.** opencode
14
+ migrated session storage from per-file JSON (`storage/session/<projectId>/<id>.json`
15
+ + `storage/message/<id>/*.json`) to a single Drizzle-managed database at
16
+ `<base>/opencode.db` (tables `session`/`message`/`part`; message text lives in
17
+ `part` rows with `data` JSON `type:"text"`). The legacy JSON layout went stale
18
+ ~2026-02, so extract discovered 0 sessions on current opencode and the
19
+ `session.idle` extract hook had nothing to read. `OpenCodeProvider` now prefers
20
+ `opencode.db` when present (read-only, via the cross-driver `openDatabase` seam)
21
+ and falls back to the JSON layout. Verified end-to-end through the plugin's
22
+ `session.idle` hook.
23
+
9
24
  ## [0.9.0-beta.33] — 2026-06-21
10
25
 
11
26
  ### Fixed
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { openDatabase } from "../../../storage/database.js";
7
8
  import { extractInlineRefMentions } from "../../session-logs/inline-refs.js";
8
9
  function getOpenCodeBaseDir() {
9
10
  if (process.platform === "darwin") {
@@ -12,28 +13,48 @@ function getOpenCodeBaseDir() {
12
13
  return path.join(os.homedir(), ".local", "share", "opencode");
13
14
  }
14
15
  /**
15
- * Opencode storage layout (observed 2026-05):
16
- * <base>/storage/session/<projectId>/<sessionId>.json — metadata
17
- * <base>/storage/message/<sessionId>/<messageId>.json one per message
16
+ * Opencode storage layouts:
17
+ *
18
+ * SQLite (current, observed 2026-06): `<base>/opencode.db` a Drizzle-managed
19
+ * database with `session` / `message` / `part` tables. Message text lives in
20
+ * `part` rows (`data` JSON, `type: "text"`); `message.data` holds role/timing.
21
+ * This is the layout current opencode builds write; it is preferred whenever
22
+ * `opencode.db` exists.
23
+ *
24
+ * JSON files (legacy, observed 2026-05): `<base>/storage/session/<projectId>/
25
+ * <sessionId>.json` (metadata) + `<base>/storage/message/<sessionId>/
26
+ * <messageId>.json` (one per message). Read only when `opencode.db` is absent.
18
27
  *
19
28
  * Older builds wrote logs directly into `<base>/log/` and `<base>/*.log`;
20
29
  * those are still scanned by {@link OpenCodeProvider.readEvents} for
21
30
  * backward compatibility with the existing failure-pattern aggregator.
22
31
  */
32
+ /** Filename of opencode's SQLite session store, relative to its base dir. */
33
+ const OPENCODE_DB_FILENAME = "opencode.db";
23
34
  export class OpenCodeProvider {
24
35
  name = "opencode";
25
36
  #baseDir = getOpenCodeBaseDir();
26
37
  isAvailable() {
27
38
  return fs.existsSync(this.#baseDir);
28
39
  }
40
+ /** Absolute path to opencode's SQLite store under `base`. */
41
+ #dbPath(base) {
42
+ return path.join(base, OPENCODE_DB_FILENAME);
43
+ }
29
44
  /**
30
- * Directory holding opencode's per-project session metadata files
31
- * (`<base>/storage/session`). Returns `[]` when it does not exist on this
32
- * machine. See {@link SessionLogHarness.watchRoots}.
45
+ * Directories/files opencode writes session data under. Returns the base dir
46
+ * when the SQLite store (`opencode.db`) exists, the legacy JSON session root
47
+ * (`<base>/storage/session`) when present, or both during a migration overlap.
48
+ * Empty when neither exists. See {@link SessionLogHarness.watchRoots}.
33
49
  */
34
50
  watchRoots() {
51
+ const roots = [];
52
+ if (fs.existsSync(this.#dbPath(this.#baseDir)))
53
+ roots.push(this.#baseDir);
35
54
  const sessionRoot = path.join(this.#baseDir, "storage", "session");
36
- return fs.existsSync(sessionRoot) ? [sessionRoot] : [];
55
+ if (fs.existsSync(sessionRoot))
56
+ roots.push(sessionRoot);
57
+ return roots;
37
58
  }
38
59
  *readEvents(input) {
39
60
  // Legacy behavior: stream raw log lines from the top-level dir and `log/`
@@ -91,6 +112,9 @@ export class OpenCodeProvider {
91
112
  listSessions(input = {}) {
92
113
  const base = input.location ?? this.#baseDir;
93
114
  const sinceMs = input.sinceMs ?? 0;
115
+ const dbPath = this.#dbPath(base);
116
+ if (fs.existsSync(dbPath))
117
+ return this.#listSessionsFromDb(dbPath, sinceMs);
94
118
  const sessionRoot = path.join(base, "storage", "session");
95
119
  if (!fs.existsSync(sessionRoot))
96
120
  return [];
@@ -151,6 +175,8 @@ export class OpenCodeProvider {
151
175
  return summaries.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
152
176
  }
153
177
  readSession(ref) {
178
+ if (path.basename(ref.filePath) === OPENCODE_DB_FILENAME)
179
+ return this.#readSessionFromDb(ref);
154
180
  let meta = {};
155
181
  try {
156
182
  meta = JSON.parse(fs.readFileSync(ref.filePath, "utf8"));
@@ -208,6 +234,141 @@ export class OpenCodeProvider {
208
234
  inlineRefs,
209
235
  };
210
236
  }
237
+ /**
238
+ * List sessions from the SQLite store. `filePath` on each summary is the
239
+ * `opencode.db` path so {@link readSession} can route back to the DB reader.
240
+ * Returns `[]` (never throws) when the DB is unreadable or lacks the expected
241
+ * schema — callers treat a missing harness as "no sessions".
242
+ */
243
+ #listSessionsFromDb(dbPath, sinceMs) {
244
+ let db;
245
+ try {
246
+ db = openDatabase(dbPath, { readonly: true, create: false });
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ try {
252
+ const rows = db
253
+ .prepare("SELECT id, title, directory, time_created, time_updated FROM session WHERE time_updated >= ? ORDER BY time_updated DESC")
254
+ .all(sinceMs);
255
+ return rows.map((r) => {
256
+ const startedAt = typeof r.time_created === "number" ? r.time_created : undefined;
257
+ const endedAt = typeof r.time_updated === "number" ? r.time_updated : undefined;
258
+ const title = typeof r.title === "string" && r.title.length > 0 ? r.title : undefined;
259
+ const projectHint = typeof r.directory === "string" && r.directory.length > 0 ? r.directory : undefined;
260
+ return {
261
+ harness: this.name,
262
+ sessionId: r.id,
263
+ filePath: dbPath,
264
+ ...(startedAt !== undefined ? { startedAt } : {}),
265
+ ...(endedAt !== undefined ? { endedAt } : {}),
266
+ ...(projectHint ? { projectHint } : {}),
267
+ ...(title ? { title } : {}),
268
+ };
269
+ });
270
+ }
271
+ catch {
272
+ // Missing `session` table / unexpected schema — treat as no sessions.
273
+ return [];
274
+ }
275
+ finally {
276
+ db.close();
277
+ }
278
+ }
279
+ /**
280
+ * Read one session from the SQLite store. Message text lives in `part` rows
281
+ * (`type: "text"`); `message.data` carries role + timing. One event per
282
+ * message, text-parts concatenated in time order. Returns empty events
283
+ * (never throws) when the DB is unreadable.
284
+ */
285
+ #readSessionFromDb(ref) {
286
+ const emptyRef = { harness: this.name, sessionId: ref.sessionId, filePath: ref.filePath };
287
+ let db;
288
+ try {
289
+ db = openDatabase(ref.filePath, { readonly: true, create: false });
290
+ }
291
+ catch {
292
+ return { ref: emptyRef, events: [], inlineRefs: [] };
293
+ }
294
+ try {
295
+ const meta = db
296
+ .prepare("SELECT title, directory, time_created, time_updated FROM session WHERE id = ?")
297
+ .get(ref.sessionId);
298
+ const startedAt = typeof meta?.time_created === "number" ? meta.time_created : undefined;
299
+ const endedAt = typeof meta?.time_updated === "number" ? meta.time_updated : undefined;
300
+ const title = typeof meta?.title === "string" && meta.title.length > 0 ? meta.title : undefined;
301
+ const projectHint = typeof meta?.directory === "string" && meta.directory.length > 0 ? meta.directory : undefined;
302
+ const messages = db
303
+ .prepare("SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC")
304
+ .all(ref.sessionId);
305
+ const parts = db
306
+ .prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC")
307
+ .all(ref.sessionId);
308
+ // Group text-part bodies by their parent message.
309
+ const textByMessage = new Map();
310
+ for (const part of parts) {
311
+ let parsed;
312
+ try {
313
+ parsed = JSON.parse(part.data);
314
+ }
315
+ catch {
316
+ continue;
317
+ }
318
+ if (parsed?.type !== "text")
319
+ continue;
320
+ const text = parsed.text;
321
+ if (typeof text !== "string" || text.length < 1)
322
+ continue;
323
+ const bucket = textByMessage.get(part.message_id) ?? [];
324
+ bucket.push(text);
325
+ textByMessage.set(part.message_id, bucket);
326
+ }
327
+ const events = [];
328
+ const inlineRefs = [];
329
+ for (const message of messages) {
330
+ let mdata = {};
331
+ try {
332
+ mdata = JSON.parse(message.data);
333
+ }
334
+ catch {
335
+ // role/timing unavailable — fall through with defaults
336
+ }
337
+ const role = typeof mdata.role === "string" ? mdata.role : "unknown";
338
+ const mtime = mdata.time?.created;
339
+ const ts = typeof mtime === "number"
340
+ ? mtime
341
+ : typeof message.time_created === "number"
342
+ ? message.time_created
343
+ : undefined;
344
+ const text = (textByMessage.get(message.id) ?? []).join("\n").trim();
345
+ if (text.length < 1)
346
+ continue;
347
+ events.push({ harness: this.name, text, ts, sessionId: ref.sessionId, role, filePath: ref.filePath });
348
+ inlineRefs.push(...extractInlineRefMentions(text, ts));
349
+ }
350
+ events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
351
+ return {
352
+ ref: {
353
+ harness: this.name,
354
+ sessionId: ref.sessionId,
355
+ filePath: ref.filePath,
356
+ ...(startedAt !== undefined ? { startedAt } : {}),
357
+ ...(endedAt !== undefined ? { endedAt } : {}),
358
+ ...(projectHint ? { projectHint } : {}),
359
+ ...(title ? { title } : {}),
360
+ },
361
+ events,
362
+ inlineRefs,
363
+ };
364
+ }
365
+ catch {
366
+ return { ref: emptyRef, events: [], inlineRefs: [] };
367
+ }
368
+ finally {
369
+ db.close();
370
+ }
371
+ }
211
372
  /**
212
373
  * Derive opencode base dir from a session metadata file path so a caller
213
374
  * passing a custom `--location` can still find the message dir.
@@ -10934,9 +10934,17 @@ class OpenCodeProvider {
10934
10934
  isAvailable() {
10935
10935
  return fs10.existsSync(this.#baseDir);
10936
10936
  }
10937
+ #dbPath(base) {
10938
+ return path9.join(base, OPENCODE_DB_FILENAME);
10939
+ }
10937
10940
  watchRoots() {
10941
+ const roots = [];
10942
+ if (fs10.existsSync(this.#dbPath(this.#baseDir)))
10943
+ roots.push(this.#baseDir);
10938
10944
  const sessionRoot = path9.join(this.#baseDir, "storage", "session");
10939
- return fs10.existsSync(sessionRoot) ? [sessionRoot] : [];
10945
+ if (fs10.existsSync(sessionRoot))
10946
+ roots.push(sessionRoot);
10947
+ return roots;
10940
10948
  }
10941
10949
  *readEvents(input) {
10942
10950
  const candidates = [this.#baseDir, path9.join(this.#baseDir, "log")];
@@ -10985,6 +10993,9 @@ class OpenCodeProvider {
10985
10993
  listSessions(input = {}) {
10986
10994
  const base = input.location ?? this.#baseDir;
10987
10995
  const sinceMs = input.sinceMs ?? 0;
10996
+ const dbPath = this.#dbPath(base);
10997
+ if (fs10.existsSync(dbPath))
10998
+ return this.#listSessionsFromDb(dbPath, sinceMs);
10988
10999
  const sessionRoot = path9.join(base, "storage", "session");
10989
11000
  if (!fs10.existsSync(sessionRoot))
10990
11001
  return [];
@@ -11039,6 +11050,8 @@ class OpenCodeProvider {
11039
11050
  return summaries.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
11040
11051
  }
11041
11052
  readSession(ref) {
11053
+ if (path9.basename(ref.filePath) === OPENCODE_DB_FILENAME)
11054
+ return this.#readSessionFromDb(ref);
11042
11055
  let meta = {};
11043
11056
  try {
11044
11057
  meta = JSON.parse(fs10.readFileSync(ref.filePath, "utf8"));
@@ -11088,6 +11101,106 @@ class OpenCodeProvider {
11088
11101
  inlineRefs
11089
11102
  };
11090
11103
  }
11104
+ #listSessionsFromDb(dbPath, sinceMs) {
11105
+ let db;
11106
+ try {
11107
+ db = openDatabase(dbPath, { readonly: true, create: false });
11108
+ } catch {
11109
+ return [];
11110
+ }
11111
+ try {
11112
+ const rows = db.prepare("SELECT id, title, directory, time_created, time_updated FROM session WHERE time_updated >= ? ORDER BY time_updated DESC").all(sinceMs);
11113
+ return rows.map((r) => {
11114
+ const startedAt = typeof r.time_created === "number" ? r.time_created : undefined;
11115
+ const endedAt = typeof r.time_updated === "number" ? r.time_updated : undefined;
11116
+ const title = typeof r.title === "string" && r.title.length > 0 ? r.title : undefined;
11117
+ const projectHint = typeof r.directory === "string" && r.directory.length > 0 ? r.directory : undefined;
11118
+ return {
11119
+ harness: this.name,
11120
+ sessionId: r.id,
11121
+ filePath: dbPath,
11122
+ ...startedAt !== undefined ? { startedAt } : {},
11123
+ ...endedAt !== undefined ? { endedAt } : {},
11124
+ ...projectHint ? { projectHint } : {},
11125
+ ...title ? { title } : {}
11126
+ };
11127
+ });
11128
+ } catch {
11129
+ return [];
11130
+ } finally {
11131
+ db.close();
11132
+ }
11133
+ }
11134
+ #readSessionFromDb(ref) {
11135
+ const emptyRef = { harness: this.name, sessionId: ref.sessionId, filePath: ref.filePath };
11136
+ let db;
11137
+ try {
11138
+ db = openDatabase(ref.filePath, { readonly: true, create: false });
11139
+ } catch {
11140
+ return { ref: emptyRef, events: [], inlineRefs: [] };
11141
+ }
11142
+ try {
11143
+ const meta = db.prepare("SELECT title, directory, time_created, time_updated FROM session WHERE id = ?").get(ref.sessionId);
11144
+ const startedAt = typeof meta?.time_created === "number" ? meta.time_created : undefined;
11145
+ const endedAt = typeof meta?.time_updated === "number" ? meta.time_updated : undefined;
11146
+ const title = typeof meta?.title === "string" && meta.title.length > 0 ? meta.title : undefined;
11147
+ const projectHint = typeof meta?.directory === "string" && meta.directory.length > 0 ? meta.directory : undefined;
11148
+ const messages = db.prepare("SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC").all(ref.sessionId);
11149
+ const parts = db.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC").all(ref.sessionId);
11150
+ const textByMessage = new Map;
11151
+ for (const part of parts) {
11152
+ let parsed;
11153
+ try {
11154
+ parsed = JSON.parse(part.data);
11155
+ } catch {
11156
+ continue;
11157
+ }
11158
+ if (parsed?.type !== "text")
11159
+ continue;
11160
+ const text = parsed.text;
11161
+ if (typeof text !== "string" || text.length < 1)
11162
+ continue;
11163
+ const bucket = textByMessage.get(part.message_id) ?? [];
11164
+ bucket.push(text);
11165
+ textByMessage.set(part.message_id, bucket);
11166
+ }
11167
+ const events = [];
11168
+ const inlineRefs = [];
11169
+ for (const message of messages) {
11170
+ let mdata = {};
11171
+ try {
11172
+ mdata = JSON.parse(message.data);
11173
+ } catch {}
11174
+ const role = typeof mdata.role === "string" ? mdata.role : "unknown";
11175
+ const mtime = mdata.time?.created;
11176
+ const ts = typeof mtime === "number" ? mtime : typeof message.time_created === "number" ? message.time_created : undefined;
11177
+ const text = (textByMessage.get(message.id) ?? []).join(`
11178
+ `).trim();
11179
+ if (text.length < 1)
11180
+ continue;
11181
+ events.push({ harness: this.name, text, ts, sessionId: ref.sessionId, role, filePath: ref.filePath });
11182
+ inlineRefs.push(...extractInlineRefMentions(text, ts));
11183
+ }
11184
+ events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
11185
+ return {
11186
+ ref: {
11187
+ harness: this.name,
11188
+ sessionId: ref.sessionId,
11189
+ filePath: ref.filePath,
11190
+ ...startedAt !== undefined ? { startedAt } : {},
11191
+ ...endedAt !== undefined ? { endedAt } : {},
11192
+ ...projectHint ? { projectHint } : {},
11193
+ ...title ? { title } : {}
11194
+ },
11195
+ events,
11196
+ inlineRefs
11197
+ };
11198
+ } catch {
11199
+ return { ref: emptyRef, events: [], inlineRefs: [] };
11200
+ } finally {
11201
+ db.close();
11202
+ }
11203
+ }
11091
11204
  #inferBaseFromSessionPath(filePath) {
11092
11205
  const dir = path9.dirname(filePath);
11093
11206
  const parts = dir.split(path9.sep);
@@ -11135,7 +11248,9 @@ class OpenCodeProvider {
11135
11248
  };
11136
11249
  }
11137
11250
  }
11251
+ var OPENCODE_DB_FILENAME = "opencode.db";
11138
11252
  var init_session_log2 = __esm(() => {
11253
+ init_database();
11139
11254
  init_inline_refs();
11140
11255
  });
11141
11256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.33",
3
+ "version": "0.9.0-beta.34",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [