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 { getExtractedSessionsMap, openStateDatabase, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../core/state-db.js";
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
- const sinceMs = parseSinceArg(effectiveSince);
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
- const sinceMs = parseSinceArg(effectiveSince);
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
- const configuredMinNewSessions = extractConfig.profiles?.improve?.default?.processes?.extract?.minNewSessions;
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`.
@@ -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.34",
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": [