akm-cli 0.9.0-beta.34 → 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
CHANGED
|
@@ -6,6 +6,34 @@ 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
|
+
|
|
9
37
|
## [0.9.0-beta.34] — 2026-06-21
|
|
10
38
|
|
|
11
39
|
### 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
|
|
@@ -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;
|
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": [
|