akm-cli 0.9.0-beta.32 → 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,37 @@ 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
|
+
|
|
24
|
+
## [0.9.0-beta.33] — 2026-06-21
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`akm extract` decoupled from the improve-stage toggle.** `processes.extract.enabled`
|
|
29
|
+
now gates extract only as a STAGE of `akm improve` (the active improve profile, per
|
|
30
|
+
#593/#594); an explicit `akm extract` command always runs. Previously dropping extract
|
|
31
|
+
from the daily improve profile silently disabled the standalone command (and its LLM
|
|
32
|
+
calls, via the shared `session_extraction` feature gate).
|
|
33
|
+
- **`extract --session-id` now respects the content-hash ledger; `--force` overrides.**
|
|
34
|
+
Explicit single-session extraction previously bypassed the #602 already-extracted skip
|
|
35
|
+
unconditionally — re-paying the LLM on every call and risking double-extraction against
|
|
36
|
+
the cron. Now a targeted `extract --session-id <id>` is idempotent (skips an unchanged,
|
|
37
|
+
already-extracted session with zero LLM calls) and only `--force` re-extracts. This
|
|
38
|
+
makes a session-end hook firing `extract --session-id <id>` precise AND idempotent.
|
|
39
|
+
|
|
9
40
|
## [0.9.0-beta.32] — 2026-06-21
|
|
10
41
|
|
|
11
42
|
### Added
|
|
@@ -37,7 +37,7 @@ import { getAvailableHarnesses } from "../../integrations/session-logs/index.js"
|
|
|
37
37
|
import { preFilterSession } from "../../integrations/session-logs/pre-filter.js";
|
|
38
38
|
import { chatCompletion } from "../../llm/client.js";
|
|
39
39
|
import { embed } from "../../llm/embedder.js";
|
|
40
|
-
import {
|
|
40
|
+
import { tryLlmFeature } from "../../llm/feature-gate.js";
|
|
41
41
|
import { sha256Hex } from "../../runtime.js";
|
|
42
42
|
import { createProposal, isProposalSkipped } from "../proposal/validators/proposals.js";
|
|
43
43
|
import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload } from "./extract-prompt.js";
|
|
@@ -180,7 +180,7 @@ triage, sessionIndexing, schemaSimilarityCtx,
|
|
|
180
180
|
// prior row + bypass flags are threaded in from the caller. Skipping here still
|
|
181
181
|
// costs ZERO LLM calls (the expensive resource #602 protects); only the cheap
|
|
182
182
|
// file read is incurred.
|
|
183
|
-
prior, force
|
|
183
|
+
prior, force) {
|
|
184
184
|
const warnings = [];
|
|
185
185
|
let data;
|
|
186
186
|
try {
|
|
@@ -201,10 +201,12 @@ prior, force, singleSession) {
|
|
|
201
201
|
// #602 — content-hash skip. Computed on the RAW event stream immediately after
|
|
202
202
|
// a successful read, BEFORE the pre-filter / minContentChars / triage gates, so
|
|
203
203
|
// an unchanged session never reaches the LLM. Hash-based ⇒ clock-independent
|
|
204
|
-
// (immune to the Jun 11-12 timestamp double-extract/over-throttle bug).
|
|
205
|
-
//
|
|
204
|
+
// (immune to the Jun 11-12 timestamp double-extract/over-throttle bug). The skip
|
|
205
|
+
// applies UNIFORMLY — including explicit `--session-id` targeting (so a
|
|
206
|
+
// session-end hook firing `extract --session-id <id>` is idempotent). ONLY
|
|
207
|
+
// `--force` overrides it to re-extract a previously-extracted session.
|
|
206
208
|
const contentHash = hashSessionContent(data);
|
|
207
|
-
if (!force &&
|
|
209
|
+
if (!force && shouldSkipAlreadyExtractedSession(prior, contentHash)) {
|
|
208
210
|
return {
|
|
209
211
|
sessionId: sessionRef.sessionId,
|
|
210
212
|
harness: harness.name,
|
|
@@ -470,9 +472,13 @@ export async function akmExtract(options) {
|
|
|
470
472
|
const extractProcess = activeProfile
|
|
471
473
|
? activeProfile.processes?.extract
|
|
472
474
|
: config.profiles?.improve?.default?.processes?.extract;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
475
|
+
// The `extract.enabled` process toggle gates extract as a STAGE of `akm improve`
|
|
476
|
+
// (the activeProfile path) — consistent with #593/#594 where the active profile,
|
|
477
|
+
// not `default`, is the source of truth. An EXPLICIT `akm extract` invocation
|
|
478
|
+
// (no activeProfile) is a direct user/cron action and always runs; gating it on
|
|
479
|
+
// the default improve profile's stage toggle was a footgun — dropping extract
|
|
480
|
+
// from the daily improve profile would silently disable the standalone command.
|
|
481
|
+
const extractEnabled = activeProfile ? resolveProcessEnabled("extract", activeProfile) : true;
|
|
476
482
|
// Feature-gate early so we get a clean "skipped because disabled" envelope.
|
|
477
483
|
if (!extractEnabled) {
|
|
478
484
|
return {
|
|
@@ -685,7 +691,7 @@ export async function akmExtract(options) {
|
|
|
685
691
|
break;
|
|
686
692
|
}
|
|
687
693
|
try {
|
|
688
|
-
const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true
|
|
694
|
+
const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true);
|
|
689
695
|
sessions.push(result);
|
|
690
696
|
// #626 — triage aggregation. A session reached the triage gate only when it
|
|
691
697
|
// was NOT already preempted by an earlier skip (read_failed / too_short /
|
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
31
|
-
* (
|
|
32
|
-
*
|
|
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
|
-
|
|
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.
|
package/dist/llm/feature-gate.js
CHANGED
|
@@ -30,10 +30,14 @@ const FEATURE_LOCATION = {
|
|
|
30
30
|
proposal_quality_gate: (cfg) => cfg.profiles?.improve?.default?.processes?.reflect?.qualityGate?.enabled ?? false,
|
|
31
31
|
// Legacy default: false
|
|
32
32
|
memory_contradiction_detection: (cfg) => cfg.profiles?.improve?.default?.processes?.consolidate?.contradictionDetection?.enabled ?? false,
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
33
|
+
// Always on at the LLM-wrapper level. Enablement is decided ONCE at the
|
|
34
|
+
// extract entry point (`akmExtract`): the `extract.enabled` process toggle
|
|
35
|
+
// gates extract as a STAGE of `akm improve` (the active improve profile, per
|
|
36
|
+
// #593/#594), while an explicit `akm extract` command always runs. Gating the
|
|
37
|
+
// inner LLM calls on `default.processes.extract.enabled` here was a footgun —
|
|
38
|
+
// dropping extract from the daily improve profile silently disabled the
|
|
39
|
+
// standalone `akm extract` command. (cfg unused — kept for resolver signature.)
|
|
40
|
+
session_extraction: (_cfg) => true,
|
|
37
41
|
};
|
|
38
42
|
/**
|
|
39
43
|
* Pure predicate: is the named feature gate enabled in `config`?
|
|
@@ -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
|
-
|
|
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.
|
|
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": [
|