akm-cli 0.9.0-beta.33 → 0.9.0-beta.35
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 +43 -0
- package/dist/commands/improve/extract.js +134 -3
- package/dist/commands/improve/improve.js +9 -1
- package/dist/core/config/config-schema.js +5 -0
- package/dist/core/state-db.js +17 -0
- package/dist/integrations/harnesses/opencode/session-log.js +168 -7
- package/dist/scripts/migrate-storage.js +124 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,49 @@ 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.35] — 2026-06-21
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Default extract discovery window is now "since the last run" (floored at 48h),
|
|
14
|
+
not a fixed 24h.** An intermittently-online host that was off for longer than
|
|
15
|
+
the old 24h window could permanently miss sessions that ended during the gap.
|
|
16
|
+
Discovery now looks back to the last recorded extract run for the harness, never
|
|
17
|
+
less than 48h. Widening is free of redundant LLM cost — the content-hash ledger
|
|
18
|
+
skips unchanged sessions with zero LLM calls. An explicit `--since`/`defaultSince`
|
|
19
|
+
still wins.
|
|
20
|
+
- **Per-session lock prevents concurrent double-extraction.** A session-end hook
|
|
21
|
+
firing `extract --session-id` while the periodic `akm improve` extract pass runs
|
|
22
|
+
discovery could both LLM-process the SAME session (duplicate spend + near-dup
|
|
23
|
+
proposals). A per-(harness, session) advisory lock (co-located with state.db,
|
|
24
|
+
PID + age staleness recovery) now makes the second run skip without any LLM call.
|
|
25
|
+
- **`minNewSessions` is read from the ACTIVE improve profile, not always `default`.**
|
|
26
|
+
A non-default profile (e.g. `frequent`) setting `minNewSessions` was silently
|
|
27
|
+
ignored because the gate (and its candidate-count discovery window) read
|
|
28
|
+
`profiles.improve.default`. They now read the resolved active profile, matching
|
|
29
|
+
how `extract.enabled` already resolves.
|
|
30
|
+
|
|
31
|
+
### Docs
|
|
32
|
+
|
|
33
|
+
- Documented that `processes.extract.indexSessions` (default on) makes a second
|
|
34
|
+
LLM call per processed session (the session summary); set it to `false` to halve
|
|
35
|
+
per-session extract cost. Unchanged/skipped sessions still cost zero.
|
|
36
|
+
|
|
37
|
+
## [0.9.0-beta.34] — 2026-06-21
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **`akm extract --type opencode` reads opencode's SQLite session store.** opencode
|
|
42
|
+
migrated session storage from per-file JSON (`storage/session/<projectId>/<id>.json`
|
|
43
|
+
+ `storage/message/<id>/*.json`) to a single Drizzle-managed database at
|
|
44
|
+
`<base>/opencode.db` (tables `session`/`message`/`part`; message text lives in
|
|
45
|
+
`part` rows with `data` JSON `type:"text"`). The legacy JSON layout went stale
|
|
46
|
+
~2026-02, so extract discovered 0 sessions on current opencode and the
|
|
47
|
+
`session.idle` extract hook had nothing to read. `OpenCodeProvider` now prefers
|
|
48
|
+
`opencode.db` when present (read-only, via the cross-driver `openDatabase` seam)
|
|
49
|
+
and falls back to the JSON layout. Verified end-to-end through the plugin's
|
|
50
|
+
`session.idle` hook.
|
|
51
|
+
|
|
9
52
|
## [0.9.0-beta.33] — 2026-06-21
|
|
10
53
|
|
|
11
54
|
### Fixed
|
|
@@ -23,12 +23,15 @@
|
|
|
23
23
|
* descriptionQualityValidator passes — same pattern as the
|
|
24
24
|
* consolidate-writer fix.
|
|
25
25
|
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
26
28
|
import { assembleAsset } from "../../core/asset/asset-serialize.js";
|
|
27
29
|
import { resolveStashDir, timestampForFilename } from "../../core/common.js";
|
|
28
30
|
import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
|
|
29
31
|
import { ConfigError, UsageError } from "../../core/errors.js";
|
|
30
32
|
import { appendEvent } from "../../core/events.js";
|
|
31
|
-
import {
|
|
33
|
+
import { probeLock, releaseLock, tryAcquireLockSync } from "../../core/file-lock.js";
|
|
34
|
+
import { getExtractedSessionsMap, getLastExtractRunAt, getStateDbPath, openStateDatabase, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../core/state-db.js";
|
|
32
35
|
import { repairTruncatedDescription } from "../../core/text-truncation.js";
|
|
33
36
|
import { warn } from "../../core/warn.js";
|
|
34
37
|
import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
|
|
@@ -63,6 +66,88 @@ const DEFAULT_MIN_CONTENT_CHARS = 10;
|
|
|
63
66
|
* processed by subsequent runs, so coverage is preserved — just spread out.
|
|
64
67
|
*/
|
|
65
68
|
const DEFAULT_MAX_SESSIONS_PER_RUN = 25;
|
|
69
|
+
/**
|
|
70
|
+
* Floor for the default discovery window (48h). When no explicit `--since` /
|
|
71
|
+
* `defaultSince` is configured, discovery looks back to the LAST recorded
|
|
72
|
+
* extract run for the harness (so an intermittently-online host that was off for
|
|
73
|
+
* days still rediscovers sessions that ended during the gap), but never LESS
|
|
74
|
+
* than this — looking back less than the prior window could drop a session that
|
|
75
|
+
* a previous run deferred via `maxSessionsPerRun`. Widening is free of redundant
|
|
76
|
+
* LLM cost: the content-hash ledger skips unchanged sessions with zero LLM calls.
|
|
77
|
+
*/
|
|
78
|
+
const DEFAULT_SINCE_FLOOR_MS = 48 * 60 * 60 * 1000;
|
|
79
|
+
/**
|
|
80
|
+
* Staleness window for the per-session extract lock. A single session's
|
|
81
|
+
* processing is bounded by the per-session LLM timeout (default 60s) plus the
|
|
82
|
+
* session-summary call, so a lock older than this must belong to a crashed
|
|
83
|
+
* holder and is safe to reclaim.
|
|
84
|
+
*/
|
|
85
|
+
const EXTRACT_SESSION_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the discovery `sinceMs` cutoff when no explicit `since`/`defaultSince`
|
|
88
|
+
* is set: the later of (last recorded extract run for this harness) and
|
|
89
|
+
* (now − 48h). See {@link DEFAULT_SINCE_FLOOR_MS}. Best-effort — any state.db
|
|
90
|
+
* error falls back to the 48h floor.
|
|
91
|
+
*/
|
|
92
|
+
function resolveDefaultSinceMs(harnessName, now, opts) {
|
|
93
|
+
const floor = now - DEFAULT_SINCE_FLOOR_MS;
|
|
94
|
+
if (opts.skipTracking)
|
|
95
|
+
return floor;
|
|
96
|
+
let db = opts.stateDb;
|
|
97
|
+
let opened = false;
|
|
98
|
+
try {
|
|
99
|
+
if (!db) {
|
|
100
|
+
db = openStateDatabase(opts.stateDbPath);
|
|
101
|
+
opened = true;
|
|
102
|
+
}
|
|
103
|
+
const lastRun = getLastExtractRunAt(db, harnessName);
|
|
104
|
+
return lastRun != null ? Math.min(lastRun, floor) : floor;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return floor;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (opened && db) {
|
|
111
|
+
try {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// best-effort close
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Filesystem-safe per-session lock path, co-located with the state.db. */
|
|
121
|
+
function getExtractSessionLockPath(harness, sessionId, stateDbPath) {
|
|
122
|
+
const safe = `${harness}-${sessionId}`.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
123
|
+
return path.join(path.dirname(stateDbPath), "extract-locks", `extract-${safe}.lock`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Try to claim the per-session extract lock so a concurrent extract (e.g. a
|
|
127
|
+
* session-end hook firing `--session-id` while the hourly improve pass runs
|
|
128
|
+
* discovery) cannot double-process the SAME session — duplicate LLM spend and
|
|
129
|
+
* near-duplicate proposals. Reclaims a stale lock (dead holder PID or age past
|
|
130
|
+
* {@link EXTRACT_SESSION_LOCK_STALE_MS}). Returns false when another LIVE run
|
|
131
|
+
* holds it — the caller then skips the session without any LLM call. Best-effort:
|
|
132
|
+
* any filesystem error resolves to `true` (proceed) so locking never blocks
|
|
133
|
+
* extraction outright.
|
|
134
|
+
*/
|
|
135
|
+
function acquireExtractSessionLock(lockPath) {
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
138
|
+
if (tryAcquireLockSync(lockPath, String(process.pid)))
|
|
139
|
+
return true;
|
|
140
|
+
const probe = probeLock(lockPath, { staleAfterMs: EXTRACT_SESSION_LOCK_STALE_MS });
|
|
141
|
+
if (probe.state === "held")
|
|
142
|
+
return false;
|
|
143
|
+
// absent (released between attempt + probe) or stale → reclaim and retry once.
|
|
144
|
+
releaseLock(lockPath);
|
|
145
|
+
return tryAcquireLockSync(lockPath, String(process.pid));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
66
151
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
67
152
|
/**
|
|
68
153
|
* Parse a since-string into an absolute ms-epoch cutoff. Accepts:
|
|
@@ -625,7 +710,16 @@ export async function akmExtract(options) {
|
|
|
625
710
|
candidates = [target];
|
|
626
711
|
}
|
|
627
712
|
else {
|
|
628
|
-
|
|
713
|
+
// No explicit `--since`/`defaultSince` → default to "since the last run"
|
|
714
|
+
// (floored at 48h) so an intermittently-online host doesn't lose sessions
|
|
715
|
+
// that ended while it was off. See {@link resolveDefaultSinceMs}.
|
|
716
|
+
const sinceMs = effectiveSince
|
|
717
|
+
? parseSinceArg(effectiveSince)
|
|
718
|
+
: resolveDefaultSinceMs(harness.name, startMs, {
|
|
719
|
+
...(options.stateDb ? { stateDb: options.stateDb } : {}),
|
|
720
|
+
...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
|
|
721
|
+
...(options.skipTracking ? { skipTracking: options.skipTracking } : {}),
|
|
722
|
+
});
|
|
629
723
|
candidates = harness.listSessions({
|
|
630
724
|
sinceMs,
|
|
631
725
|
...(options.location ? { location: options.location } : {}),
|
|
@@ -690,6 +784,31 @@ export async function akmExtract(options) {
|
|
|
690
784
|
topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${candidates.length - processedCount - skippedCount} session(s) deferred to a later run.`);
|
|
691
785
|
break;
|
|
692
786
|
}
|
|
787
|
+
// Q5 — per-session lock so two concurrent extracts (e.g. a session-end hook
|
|
788
|
+
// firing `--session-id` while the hourly improve discovery pass runs) can't
|
|
789
|
+
// both LLM-process the SAME session. The holder records the outcome; a
|
|
790
|
+
// second run skips without any LLM call. Engaged only for real cross-process
|
|
791
|
+
// runs (those that open their own state.db): dry-run is read-only, an
|
|
792
|
+
// injected `stateDb` handle is an in-process/test scenario with no cross-
|
|
793
|
+
// process race, and skip-tracking-off opts out entirely.
|
|
794
|
+
let sessionLockPath;
|
|
795
|
+
if (trackingEnabled && !dryRun && !options.stateDb) {
|
|
796
|
+
sessionLockPath = getExtractSessionLockPath(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath());
|
|
797
|
+
if (!acquireExtractSessionLock(sessionLockPath)) {
|
|
798
|
+
sessions.push({
|
|
799
|
+
sessionId: summary.sessionId,
|
|
800
|
+
harness: harness.name,
|
|
801
|
+
candidateCount: 0,
|
|
802
|
+
proposalIds: [],
|
|
803
|
+
preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
|
|
804
|
+
warnings: ["concurrent extract holds this session's lock — skipped (handled by the other run)"],
|
|
805
|
+
skipped: true,
|
|
806
|
+
skipReason: "locked_concurrent",
|
|
807
|
+
});
|
|
808
|
+
skippedCount += 1;
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
693
812
|
try {
|
|
694
813
|
const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true);
|
|
695
814
|
sessions.push(result);
|
|
@@ -779,6 +898,10 @@ export async function akmExtract(options) {
|
|
|
779
898
|
});
|
|
780
899
|
skippedCount += 1;
|
|
781
900
|
}
|
|
901
|
+
finally {
|
|
902
|
+
if (sessionLockPath)
|
|
903
|
+
releaseLock(sessionLockPath);
|
|
904
|
+
}
|
|
782
905
|
}
|
|
783
906
|
// Close the state.db connection we opened. Callers that injected stateDb
|
|
784
907
|
// via the test seam own its lifecycle.
|
|
@@ -838,13 +961,21 @@ export async function akmExtract(options) {
|
|
|
838
961
|
export function countNewExtractCandidates(config, options = {}) {
|
|
839
962
|
const extractProcess = config.profiles?.improve?.default?.processes?.extract;
|
|
840
963
|
const effectiveSince = options.since ?? extractProcess?.defaultSince;
|
|
841
|
-
|
|
964
|
+
// Mirror akmExtract: when no explicit window is set, default per-harness to
|
|
965
|
+
// "since the last run" (floored at 48h) instead of a fixed 24h. Keeps this
|
|
966
|
+
// gate's discovery window identical to what akmExtract will actually scan.
|
|
967
|
+
const explicitSinceMs = effectiveSince ? parseSinceArg(effectiveSince) : undefined;
|
|
842
968
|
const harnesses = (options.harnesses ?? getAvailableHarnesses()).filter((h) => h.isAvailable());
|
|
843
969
|
let stateDb = options.stateDb;
|
|
844
970
|
let openedStateDb = false;
|
|
845
971
|
let total = 0;
|
|
846
972
|
try {
|
|
847
973
|
for (const harness of harnesses) {
|
|
974
|
+
const sinceMs = explicitSinceMs ??
|
|
975
|
+
resolveDefaultSinceMs(harness.name, Date.now(), {
|
|
976
|
+
...(options.stateDb ? { stateDb: options.stateDb } : {}),
|
|
977
|
+
...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
|
|
978
|
+
});
|
|
848
979
|
const candidates = harness.listSessions({ sinceMs });
|
|
849
980
|
if (candidates.length === 0)
|
|
850
981
|
continue;
|
|
@@ -1887,7 +1887,10 @@ async function runImprovePreparationStage(args) {
|
|
|
1887
1887
|
// memory mtimes, so a skipped extract never flags work for the NEXT run's
|
|
1888
1888
|
// consolidation mtime-gate (the downstream trigger #554 asks us to suppress).
|
|
1889
1889
|
const EXTRACT_DEFAULT_MIN_NEW_SESSIONS = 0;
|
|
1890
|
-
|
|
1890
|
+
// Read from the ACTIVE resolved profile (not always `default`), matching how
|
|
1891
|
+
// `extract.enabled` resolves — otherwise a non-default profile (e.g.
|
|
1892
|
+
// `frequent`) setting `minNewSessions` was silently ignored.
|
|
1893
|
+
const configuredMinNewSessions = improveProfile.processes?.extract?.minNewSessions;
|
|
1891
1894
|
const minNewSessions = typeof configuredMinNewSessions === "number" ? configuredMinNewSessions : EXTRACT_DEFAULT_MIN_NEW_SESSIONS;
|
|
1892
1895
|
// #593/#594: the ACTIVE resolved improve profile is the single source of
|
|
1893
1896
|
// truth for whether extract runs. (Previously this also ANDed in the legacy
|
|
@@ -1904,6 +1907,11 @@ async function runImprovePreparationStage(args) {
|
|
|
1904
1907
|
const countFn = options.extractCandidateCountFn ?? countNewExtractCandidates;
|
|
1905
1908
|
const newCandidateCount = countFn(extractConfig, {
|
|
1906
1909
|
...(options.extractHarnesses ? { harnesses: options.extractHarnesses } : {}),
|
|
1910
|
+
// Use the ACTIVE profile's discovery window so the gate counts over the
|
|
1911
|
+
// same window akmExtract will scan (not always `default`).
|
|
1912
|
+
...(improveProfile.processes?.extract?.defaultSince
|
|
1913
|
+
? { since: improveProfile.processes.extract.defaultSince }
|
|
1914
|
+
: {}),
|
|
1907
1915
|
// C2: pin the candidate-count state.db open to the boundary-resolved path.
|
|
1908
1916
|
...(eventsCtx?.dbPath ? { stateDbPath: eventsCtx.dbPath } : {}),
|
|
1909
1917
|
});
|
|
@@ -208,6 +208,11 @@ export const ImproveProcessConfigSchema = z
|
|
|
208
208
|
maxSessionsPerRun: z.number().int().min(0).optional(),
|
|
209
209
|
// #561 — index agent sessions as a searchable `session` asset (extract
|
|
210
210
|
// process). Absent = on-when-an-LLM-is-available (fail-open when offline).
|
|
211
|
+
// COST: when on, each processed session makes a SECOND LLM call (the session
|
|
212
|
+
// summary) on top of the extraction call — i.e. ~2 LLM calls/session. Set to
|
|
213
|
+
// false to halve per-session extract cost at the price of unsearchable
|
|
214
|
+
// sessions. (Unchanged/skip sessions still cost zero — the content-hash
|
|
215
|
+
// ledger gates both calls upstream.)
|
|
211
216
|
indexSessions: z.boolean().optional(),
|
|
212
217
|
// #561 — minimum session duration in minutes for session indexing. 0
|
|
213
218
|
// disables the gate. Absent = default 5. Only meaningful on `extract`.
|
package/dist/core/state-db.js
CHANGED
|
@@ -1520,6 +1520,23 @@ export function getExtractedSessionsMap(db, harness, sessionIds) {
|
|
|
1520
1520
|
}
|
|
1521
1521
|
return out;
|
|
1522
1522
|
}
|
|
1523
|
+
/**
|
|
1524
|
+
* The most recent extract-run time for a harness — `MAX(processed_at)` across
|
|
1525
|
+
* its ledger rows, as ms epoch — or `null` when the harness has never been
|
|
1526
|
+
* extracted. Used to default the discovery window to "since the last run" so an
|
|
1527
|
+
* intermittently-online host that was off for days still rediscovers sessions
|
|
1528
|
+
* that ended during the gap (the content-hash ledger keeps the widened window
|
|
1529
|
+
* free of redundant LLM cost).
|
|
1530
|
+
*/
|
|
1531
|
+
export function getLastExtractRunAt(db, harness) {
|
|
1532
|
+
const row = db
|
|
1533
|
+
.prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?")
|
|
1534
|
+
.get(harness);
|
|
1535
|
+
if (!row?.last)
|
|
1536
|
+
return null;
|
|
1537
|
+
const ms = Date.parse(row.last);
|
|
1538
|
+
return Number.isFinite(ms) ? ms : null;
|
|
1539
|
+
}
|
|
1523
1540
|
/**
|
|
1524
1541
|
* Decide whether a session should be skipped because the extractor has already
|
|
1525
1542
|
* processed BYTE-IDENTICAL content (#602). The skip authority is the content
|
|
@@ -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.
|
|
@@ -8998,6 +8998,7 @@ __export(exports_state_db, {
|
|
|
8998
8998
|
getStateDbPath: () => getStateDbPath,
|
|
8999
8999
|
getRecombineHypothesis: () => getRecombineHypothesis,
|
|
9000
9000
|
getPhaseThreshold: () => getPhaseThreshold,
|
|
9001
|
+
getLastExtractRunAt: () => getLastExtractRunAt,
|
|
9001
9002
|
getExtractedSessionsMap: () => getExtractedSessionsMap,
|
|
9002
9003
|
getExtractedSession: () => getExtractedSession,
|
|
9003
9004
|
getConsolidationJudgedMap: () => getConsolidationJudgedMap,
|
|
@@ -9437,6 +9438,13 @@ function getExtractedSessionsMap(db, harness, sessionIds) {
|
|
|
9437
9438
|
}
|
|
9438
9439
|
return out;
|
|
9439
9440
|
}
|
|
9441
|
+
function getLastExtractRunAt(db, harness) {
|
|
9442
|
+
const row = db.prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?").get(harness);
|
|
9443
|
+
if (!row?.last)
|
|
9444
|
+
return null;
|
|
9445
|
+
const ms = Date.parse(row.last);
|
|
9446
|
+
return Number.isFinite(ms) ? ms : null;
|
|
9447
|
+
}
|
|
9440
9448
|
function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
|
|
9441
9449
|
if (!prior)
|
|
9442
9450
|
return false;
|
|
@@ -10934,9 +10942,17 @@ class OpenCodeProvider {
|
|
|
10934
10942
|
isAvailable() {
|
|
10935
10943
|
return fs10.existsSync(this.#baseDir);
|
|
10936
10944
|
}
|
|
10945
|
+
#dbPath(base) {
|
|
10946
|
+
return path9.join(base, OPENCODE_DB_FILENAME);
|
|
10947
|
+
}
|
|
10937
10948
|
watchRoots() {
|
|
10949
|
+
const roots = [];
|
|
10950
|
+
if (fs10.existsSync(this.#dbPath(this.#baseDir)))
|
|
10951
|
+
roots.push(this.#baseDir);
|
|
10938
10952
|
const sessionRoot = path9.join(this.#baseDir, "storage", "session");
|
|
10939
|
-
|
|
10953
|
+
if (fs10.existsSync(sessionRoot))
|
|
10954
|
+
roots.push(sessionRoot);
|
|
10955
|
+
return roots;
|
|
10940
10956
|
}
|
|
10941
10957
|
*readEvents(input) {
|
|
10942
10958
|
const candidates = [this.#baseDir, path9.join(this.#baseDir, "log")];
|
|
@@ -10985,6 +11001,9 @@ class OpenCodeProvider {
|
|
|
10985
11001
|
listSessions(input = {}) {
|
|
10986
11002
|
const base = input.location ?? this.#baseDir;
|
|
10987
11003
|
const sinceMs = input.sinceMs ?? 0;
|
|
11004
|
+
const dbPath = this.#dbPath(base);
|
|
11005
|
+
if (fs10.existsSync(dbPath))
|
|
11006
|
+
return this.#listSessionsFromDb(dbPath, sinceMs);
|
|
10988
11007
|
const sessionRoot = path9.join(base, "storage", "session");
|
|
10989
11008
|
if (!fs10.existsSync(sessionRoot))
|
|
10990
11009
|
return [];
|
|
@@ -11039,6 +11058,8 @@ class OpenCodeProvider {
|
|
|
11039
11058
|
return summaries.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
|
|
11040
11059
|
}
|
|
11041
11060
|
readSession(ref) {
|
|
11061
|
+
if (path9.basename(ref.filePath) === OPENCODE_DB_FILENAME)
|
|
11062
|
+
return this.#readSessionFromDb(ref);
|
|
11042
11063
|
let meta = {};
|
|
11043
11064
|
try {
|
|
11044
11065
|
meta = JSON.parse(fs10.readFileSync(ref.filePath, "utf8"));
|
|
@@ -11088,6 +11109,106 @@ class OpenCodeProvider {
|
|
|
11088
11109
|
inlineRefs
|
|
11089
11110
|
};
|
|
11090
11111
|
}
|
|
11112
|
+
#listSessionsFromDb(dbPath, sinceMs) {
|
|
11113
|
+
let db;
|
|
11114
|
+
try {
|
|
11115
|
+
db = openDatabase(dbPath, { readonly: true, create: false });
|
|
11116
|
+
} catch {
|
|
11117
|
+
return [];
|
|
11118
|
+
}
|
|
11119
|
+
try {
|
|
11120
|
+
const rows = db.prepare("SELECT id, title, directory, time_created, time_updated FROM session WHERE time_updated >= ? ORDER BY time_updated DESC").all(sinceMs);
|
|
11121
|
+
return rows.map((r) => {
|
|
11122
|
+
const startedAt = typeof r.time_created === "number" ? r.time_created : undefined;
|
|
11123
|
+
const endedAt = typeof r.time_updated === "number" ? r.time_updated : undefined;
|
|
11124
|
+
const title = typeof r.title === "string" && r.title.length > 0 ? r.title : undefined;
|
|
11125
|
+
const projectHint = typeof r.directory === "string" && r.directory.length > 0 ? r.directory : undefined;
|
|
11126
|
+
return {
|
|
11127
|
+
harness: this.name,
|
|
11128
|
+
sessionId: r.id,
|
|
11129
|
+
filePath: dbPath,
|
|
11130
|
+
...startedAt !== undefined ? { startedAt } : {},
|
|
11131
|
+
...endedAt !== undefined ? { endedAt } : {},
|
|
11132
|
+
...projectHint ? { projectHint } : {},
|
|
11133
|
+
...title ? { title } : {}
|
|
11134
|
+
};
|
|
11135
|
+
});
|
|
11136
|
+
} catch {
|
|
11137
|
+
return [];
|
|
11138
|
+
} finally {
|
|
11139
|
+
db.close();
|
|
11140
|
+
}
|
|
11141
|
+
}
|
|
11142
|
+
#readSessionFromDb(ref) {
|
|
11143
|
+
const emptyRef = { harness: this.name, sessionId: ref.sessionId, filePath: ref.filePath };
|
|
11144
|
+
let db;
|
|
11145
|
+
try {
|
|
11146
|
+
db = openDatabase(ref.filePath, { readonly: true, create: false });
|
|
11147
|
+
} catch {
|
|
11148
|
+
return { ref: emptyRef, events: [], inlineRefs: [] };
|
|
11149
|
+
}
|
|
11150
|
+
try {
|
|
11151
|
+
const meta = db.prepare("SELECT title, directory, time_created, time_updated FROM session WHERE id = ?").get(ref.sessionId);
|
|
11152
|
+
const startedAt = typeof meta?.time_created === "number" ? meta.time_created : undefined;
|
|
11153
|
+
const endedAt = typeof meta?.time_updated === "number" ? meta.time_updated : undefined;
|
|
11154
|
+
const title = typeof meta?.title === "string" && meta.title.length > 0 ? meta.title : undefined;
|
|
11155
|
+
const projectHint = typeof meta?.directory === "string" && meta.directory.length > 0 ? meta.directory : undefined;
|
|
11156
|
+
const messages = db.prepare("SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC").all(ref.sessionId);
|
|
11157
|
+
const parts = db.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC").all(ref.sessionId);
|
|
11158
|
+
const textByMessage = new Map;
|
|
11159
|
+
for (const part of parts) {
|
|
11160
|
+
let parsed;
|
|
11161
|
+
try {
|
|
11162
|
+
parsed = JSON.parse(part.data);
|
|
11163
|
+
} catch {
|
|
11164
|
+
continue;
|
|
11165
|
+
}
|
|
11166
|
+
if (parsed?.type !== "text")
|
|
11167
|
+
continue;
|
|
11168
|
+
const text = parsed.text;
|
|
11169
|
+
if (typeof text !== "string" || text.length < 1)
|
|
11170
|
+
continue;
|
|
11171
|
+
const bucket = textByMessage.get(part.message_id) ?? [];
|
|
11172
|
+
bucket.push(text);
|
|
11173
|
+
textByMessage.set(part.message_id, bucket);
|
|
11174
|
+
}
|
|
11175
|
+
const events = [];
|
|
11176
|
+
const inlineRefs = [];
|
|
11177
|
+
for (const message of messages) {
|
|
11178
|
+
let mdata = {};
|
|
11179
|
+
try {
|
|
11180
|
+
mdata = JSON.parse(message.data);
|
|
11181
|
+
} catch {}
|
|
11182
|
+
const role = typeof mdata.role === "string" ? mdata.role : "unknown";
|
|
11183
|
+
const mtime = mdata.time?.created;
|
|
11184
|
+
const ts = typeof mtime === "number" ? mtime : typeof message.time_created === "number" ? message.time_created : undefined;
|
|
11185
|
+
const text = (textByMessage.get(message.id) ?? []).join(`
|
|
11186
|
+
`).trim();
|
|
11187
|
+
if (text.length < 1)
|
|
11188
|
+
continue;
|
|
11189
|
+
events.push({ harness: this.name, text, ts, sessionId: ref.sessionId, role, filePath: ref.filePath });
|
|
11190
|
+
inlineRefs.push(...extractInlineRefMentions(text, ts));
|
|
11191
|
+
}
|
|
11192
|
+
events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
|
|
11193
|
+
return {
|
|
11194
|
+
ref: {
|
|
11195
|
+
harness: this.name,
|
|
11196
|
+
sessionId: ref.sessionId,
|
|
11197
|
+
filePath: ref.filePath,
|
|
11198
|
+
...startedAt !== undefined ? { startedAt } : {},
|
|
11199
|
+
...endedAt !== undefined ? { endedAt } : {},
|
|
11200
|
+
...projectHint ? { projectHint } : {},
|
|
11201
|
+
...title ? { title } : {}
|
|
11202
|
+
},
|
|
11203
|
+
events,
|
|
11204
|
+
inlineRefs
|
|
11205
|
+
};
|
|
11206
|
+
} catch {
|
|
11207
|
+
return { ref: emptyRef, events: [], inlineRefs: [] };
|
|
11208
|
+
} finally {
|
|
11209
|
+
db.close();
|
|
11210
|
+
}
|
|
11211
|
+
}
|
|
11091
11212
|
#inferBaseFromSessionPath(filePath) {
|
|
11092
11213
|
const dir = path9.dirname(filePath);
|
|
11093
11214
|
const parts = dir.split(path9.sep);
|
|
@@ -11135,7 +11256,9 @@ class OpenCodeProvider {
|
|
|
11135
11256
|
};
|
|
11136
11257
|
}
|
|
11137
11258
|
}
|
|
11259
|
+
var OPENCODE_DB_FILENAME = "opencode.db";
|
|
11138
11260
|
var init_session_log2 = __esm(() => {
|
|
11261
|
+
init_database();
|
|
11139
11262
|
init_inline_refs();
|
|
11140
11263
|
});
|
|
11141
11264
|
|
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.35",
|
|
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": [
|