akm-cli 0.9.0-beta.29 → 0.9.0-beta.30

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,25 @@ 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.30] — 2026-06-20
10
+
11
+ ### Changed / Fixed
12
+
13
+ - **#632 — recombine cluster tuning (opt-in, default-preserving).** Recombine
14
+ clustered memories by frontmatter tag and preferred the LARGEST buckets, so it
15
+ always picked the coarsest whole-stash tags (`session`/`claude`/`akm`, 63–171
16
+ members) and produced bland generalizations. Two new `processes.recombine` knobs:
17
+ `maxClusterSize` (skip clusters larger than N, so over-broad buckets no longer
18
+ reach/starve the largest-first slice) and `excludeTags` (tags that may never form
19
+ a tag cluster). Both UNSET = byte-identical to prior behavior.
20
+ - **#633 — recombine confirmation loop fixed.** The hypothesis confirmation streak
21
+ was keyed on a hash of the EXACT member set, so a growing stash drifted the key
22
+ every run → a fresh row at count 1 → `confirmThreshold` never reached → no
23
+ hypothesis ever promoted to a lesson (a dead two-pass loop). A freshly-induced
24
+ cluster now matches an existing pending row by signature + Jaccard
25
+ membership-overlap (≥ 0.7) and reuses its stable ref, so the streak accumulates
26
+ through membership drift. First/non-overlapping induction is unchanged.
27
+
9
28
  ## [0.9.0-beta.29] — 2026-06-20
10
29
 
11
30
  ### Reverted
@@ -3860,6 +3860,9 @@ async function runImprovePostLoopStage(args) {
3860
3860
  maxClustersPerRun: improveProfile.processes?.recombine?.maxClustersPerRun,
3861
3861
  relatednessSource: improveProfile.processes?.recombine?.relatednessSource,
3862
3862
  confirmThreshold: improveProfile.processes?.recombine?.confirmThreshold,
3863
+ // #632 — clustering-tuning knobs. UNSET = pre-#632 behaviour.
3864
+ maxClusterSize: improveProfile.processes?.recombine?.maxClusterSize,
3865
+ excludeTags: improveProfile.processes?.recombine?.excludeTags,
3863
3866
  });
3864
3867
  }
3865
3868
  catch (e) {
@@ -45,7 +45,7 @@ import { resolveStashDir } from "../../core/common.js";
45
45
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
46
46
  import { appendEvent } from "../../core/events.js";
47
47
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
48
- import { decayUnseenRecombineHypotheses, getRecombineHypothesis, getStateDbPath, markRecombineHypothesisPromoted, openStateDatabase, recordRecombineInduction, } from "../../core/state-db.js";
48
+ import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, getStateDbPath, markRecombineHypothesisPromoted, openStateDatabase, recordRecombineInduction, } from "../../core/state-db.js";
49
49
  import { warn } from "../../core/warn.js";
50
50
  import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
51
51
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
@@ -59,6 +59,13 @@ const DEFAULT_MAX_CLUSTERS_PER_RUN = 5;
59
59
  const DEFAULT_RELATEDNESS_SOURCE = "tags";
60
60
  /** #625 — re-induction count required before a hypothesis promotes to a lesson. */
61
61
  const DEFAULT_CONFIRM_THRESHOLD = 2;
62
+ /**
63
+ * #633 — Jaccard membership-overlap threshold for matching a freshly-induced
64
+ * hypothesis to an existing pending row under the SAME signature. A growing
65
+ * stash drifts the exact member set every run; an overlap >= this lets the
66
+ * confirmation streak keep accumulating under one row instead of resetting to 1.
67
+ */
68
+ const DEFAULT_RECOMBINE_OVERLAP = 0.7;
62
69
  // ── Clustering by relatedness (NOT similarity) ────────────────────────────────
63
70
  /**
64
71
  * Build relatedness clusters from the memory pool. Clustering is driven purely
@@ -99,21 +106,33 @@ export function buildRelatednessClusters(entries, opts) {
99
106
  const hasEntities = !!opts.entityByEntryId && opts.entityByEntryId.size > 0;
100
107
  const useGraph = (opts.relatednessSource === "graph" || opts.relatednessSource === "both") && hasEntities;
101
108
  const tagsFallback = !useTags && opts.relatednessSource === "graph" && !hasEntities;
109
+ // #632 — tags excluded from tag-based clustering (applies regardless of
110
+ // source). UNSET/[] leaves clustering byte-identical to the pre-#632 path.
111
+ const excludeTags = new Set(opts.excludeTags ?? []);
102
112
  for (const entry of memories) {
103
113
  if (useTags || tagsFallback) {
104
- for (const tag of entry.entry.tags ?? [])
114
+ for (const tag of entry.entry.tags ?? []) {
115
+ if (excludeTags.has(tag))
116
+ continue;
105
117
  add(`tag:${tag}`, entry);
118
+ }
106
119
  }
107
120
  if (useGraph && opts.entityByEntryId) {
108
121
  for (const ent of opts.entityByEntryId.get(entry.id) ?? [])
109
122
  add(`entity:${ent}`, entry);
110
123
  }
111
124
  }
112
- // Keep only groups at or above the minimum cluster size.
125
+ // Keep only groups at or above the minimum cluster size. #632 — when
126
+ // maxClusterSize is set, also SKIP groups strictly larger than the cap so an
127
+ // over-broad bucket never reaches (and starves) the largest-first slice.
128
+ // UNSET = no upper bound = identical to the pre-#632 behaviour.
113
129
  let clusters = [];
114
130
  for (const [signature, members] of groups) {
115
- if (members.length >= opts.minClusterSize)
116
- clusters.push({ signature, members });
131
+ if (members.length < opts.minClusterSize)
132
+ continue;
133
+ if (opts.maxClusterSize != null && members.length > opts.maxClusterSize)
134
+ continue;
135
+ clusters.push({ signature, members });
117
136
  }
118
137
  // De-duplicate clusters that share the exact same member set (e.g. a tag and
119
138
  // an entity that co-occur on the same trio). Keep the first by signature.
@@ -295,6 +314,8 @@ export async function akmRecombine(opts) {
295
314
  maxClustersPerRun,
296
315
  relatednessSource,
297
316
  ...(entityByEntryId ? { entityByEntryId } : {}),
317
+ ...(opts.maxClusterSize != null ? { maxClusterSize: opts.maxClusterSize } : {}),
318
+ ...(opts.excludeTags ? { excludeTags: opts.excludeTags } : {}),
298
319
  });
299
320
  let clustersFormed = 0;
300
321
  let proposalsEmitted = 0;
@@ -337,7 +358,24 @@ export async function akmRecombine(opts) {
337
358
  }, opts.ctx);
338
359
  continue;
339
360
  }
340
- const lessonRef = deriveRecombineLessonRef(cluster);
361
+ // #633 the confirmation identity is decoupled from the EXACT member
362
+ // set. We first look for an existing pending hypothesis row under the
363
+ // SAME signature whose membership overlaps this cluster (Jaccard >=
364
+ // threshold) and, if found, REUSE that row's stable ref so a
365
+ // drifting-but-overlapping cluster keeps accumulating its streak under one
366
+ // row instead of spawning a fresh row (count=1) every run. With no match
367
+ // (first induction, or membership drifted past the overlap floor) we fall
368
+ // back to the deterministic member-set ref exactly as before.
369
+ const memberKey = recombineMemberKey(cluster);
370
+ const derivedRef = deriveRecombineLessonRef(cluster);
371
+ const matchedRow = stateDb
372
+ ? findMatchingRecombineHypothesis(stateDb, {
373
+ signature: cluster.signature,
374
+ memberKey,
375
+ minOverlap: DEFAULT_RECOMBINE_OVERLAP,
376
+ })
377
+ : undefined;
378
+ const lessonRef = matchedRow?.hypothesis_ref ?? derivedRef;
341
379
  const sourceRefs = cluster.members.map((m) => `memory:${m.entry.name}`);
342
380
  // Quality gate (always-run): the frontmatter description must be present
343
381
  // and non-truncated. This runs BEFORE createProposal on BOTH the
@@ -360,9 +398,11 @@ export async function akmRecombine(opts) {
360
398
  // A defensible generalization was produced this run — record it so it is
361
399
  // NOT decayed by the unseen sweep below.
362
400
  seenThisRun.add(lessonRef);
363
- // #625 — record the re-induction and read the prior promotion state. The
364
- // member key and the ref hash derive from the SAME member set, so
365
- // re-induction of an identical cluster maps back to the same row.
401
+ // #625/#633 — record the re-induction and read the prior promotion state.
402
+ // `lessonRef` is the matched row's ref (overlap match) or the freshly
403
+ // derived member-set ref (first/non-overlapping induction). The induction
404
+ // refreshes the row's `member_key` to the current membership so the
405
+ // overlap window slides with the drifting cluster.
366
406
  const nowIso = new Date().toISOString();
367
407
  const priorRow = stateDb ? getRecombineHypothesis(stateDb, lessonRef) : undefined;
368
408
  const alreadyPromoted = priorRow?.promoted_at != null;
@@ -370,7 +410,7 @@ export async function akmRecombine(opts) {
370
410
  ? recordRecombineInduction(stateDb, {
371
411
  hypothesisRef: lessonRef,
372
412
  signature: cluster.signature,
373
- memberKey: recombineMemberKey(cluster),
413
+ memberKey,
374
414
  seenAt: nowIso,
375
415
  run: sourceRun,
376
416
  })
@@ -302,6 +302,15 @@ export const ImproveProcessConfigSchema = z
302
302
  // #609 — recombine process: hard cap on clusters processed per run (one
303
303
  // bounded LLM call each). Default 5. Only meaningful on `recombine`.
304
304
  maxClustersPerRun: positiveInt.optional(),
305
+ // #632 — recombine process: max members a cluster may contain before it is
306
+ // SKIPPED (drops bland over-broad buckets). When set, largest-first ranking
307
+ // no longer starves tighter clusters. Default UNSET = no cap. Only
308
+ // meaningful on `recombine`.
309
+ maxClusterSize: positiveInt.optional(),
310
+ // #632 — recombine process: tag values that must never form a tag cluster
311
+ // (generic project-wide tags). Default UNSET/[]. Only meaningful on
312
+ // `recombine`.
313
+ excludeTags: z.array(z.string().min(1)).optional(),
305
314
  // #609 — recombine process: relatedness signal used to form clusters
306
315
  // (tags | graph | both). Clustering is by relatedness, never embedding
307
316
  // similarity. Default "tags". Only meaningful on `recombine`.
@@ -1599,6 +1599,54 @@ export function recordRecombineInduction(db, input) {
1599
1599
  .get(input.hypothesisRef, input.signature, input.memberKey, input.seenAt, input.seenAt, input.run);
1600
1600
  return row?.consecutive_count ?? 0;
1601
1601
  }
1602
+ /**
1603
+ * #633 — find an existing pending (non-promoted) hypothesis row whose cluster
1604
+ * is the SAME generalization as a newly-induced one, matched by SIGNATURE plus
1605
+ * a Jaccard membership-overlap test, rather than an exact member-set hash.
1606
+ *
1607
+ * In a growing stash any added/removed memory changes the exact member set, so
1608
+ * the ref hash (and member_key) shift every run → a fresh row at count=1 → the
1609
+ * streak never reaches `confirmThreshold` and nothing ever promotes. Matching
1610
+ * on overlap lets a drifting-but-stable cluster keep accumulating under one row.
1611
+ *
1612
+ * Returns the matched row with the HIGHEST overlap (ties broken by most-recent
1613
+ * `last_seen_at`), or `undefined` when none clears `minOverlap`. Already-promoted
1614
+ * rows are ignored so a confirmed lesson is not reopened by a later induction.
1615
+ *
1616
+ * @param memberKey the candidate cluster's membership fingerprint
1617
+ * (sorted member entryKeys joined by `|`).
1618
+ * @param minOverlap Jaccard threshold in [0,1]; a candidate matches when
1619
+ * |A∩B| / |A∪B| >= minOverlap.
1620
+ */
1621
+ export function findMatchingRecombineHypothesis(db, input) {
1622
+ const candidateMembers = new Set(input.memberKey.split("|").filter((m) => m.length > 0));
1623
+ if (candidateMembers.size === 0)
1624
+ return undefined;
1625
+ const rows = db
1626
+ .prepare("SELECT * FROM recombine_hypotheses WHERE signature = ? AND promoted_at IS NULL ORDER BY last_seen_at DESC")
1627
+ .all(input.signature);
1628
+ let best;
1629
+ let bestOverlap = -1;
1630
+ for (const row of rows) {
1631
+ const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
1632
+ if (rowMembers.length === 0)
1633
+ continue;
1634
+ let intersection = 0;
1635
+ for (const m of rowMembers) {
1636
+ if (candidateMembers.has(m))
1637
+ intersection += 1;
1638
+ }
1639
+ const union = candidateMembers.size + rowMembers.length - intersection;
1640
+ const overlap = union === 0 ? 0 : intersection / union;
1641
+ // rows are ordered last_seen_at DESC, so a strict `>` keeps the most-recent
1642
+ // row on ties.
1643
+ if (overlap >= input.minOverlap && overlap > bestOverlap) {
1644
+ best = row;
1645
+ bestOverlap = overlap;
1646
+ }
1647
+ }
1648
+ return best;
1649
+ }
1602
1650
  /**
1603
1651
  * Fetch a single recombine hypothesis row, or `undefined` when the ref has
1604
1652
  * never been induced. Normalizes bun:sqlite null → undefined like
@@ -9002,6 +9002,7 @@ __export(exports_state_db, {
9002
9002
  getExtractedSession: () => getExtractedSession,
9003
9003
  getConsolidationJudgedMap: () => getConsolidationJudgedMap,
9004
9004
  getBodyEmbeddings: () => getBodyEmbeddings,
9005
+ findMatchingRecombineHypothesis: () => findMatchingRecombineHypothesis,
9005
9006
  eventRowToEnvelope: () => eventRowToEnvelope,
9006
9007
  embeddingToBlob: () => embeddingToBlob,
9007
9008
  decayUnseenRecombineHypotheses: () => decayUnseenRecombineHypotheses,
@@ -9479,6 +9480,31 @@ function recordRecombineInduction(db, input) {
9479
9480
  `).get(input.hypothesisRef, input.signature, input.memberKey, input.seenAt, input.seenAt, input.run);
9480
9481
  return row?.consecutive_count ?? 0;
9481
9482
  }
9483
+ function findMatchingRecombineHypothesis(db, input) {
9484
+ const candidateMembers = new Set(input.memberKey.split("|").filter((m) => m.length > 0));
9485
+ if (candidateMembers.size === 0)
9486
+ return;
9487
+ const rows = db.prepare("SELECT * FROM recombine_hypotheses WHERE signature = ? AND promoted_at IS NULL ORDER BY last_seen_at DESC").all(input.signature);
9488
+ let best;
9489
+ let bestOverlap = -1;
9490
+ for (const row of rows) {
9491
+ const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
9492
+ if (rowMembers.length === 0)
9493
+ continue;
9494
+ let intersection = 0;
9495
+ for (const m of rowMembers) {
9496
+ if (candidateMembers.has(m))
9497
+ intersection += 1;
9498
+ }
9499
+ const union = candidateMembers.size + rowMembers.length - intersection;
9500
+ const overlap = union === 0 ? 0 : intersection / union;
9501
+ if (overlap >= input.minOverlap && overlap > bestOverlap) {
9502
+ best = row;
9503
+ bestOverlap = overlap;
9504
+ }
9505
+ }
9506
+ return best;
9507
+ }
9482
9508
  function getRecombineHypothesis(db, hypothesisRef) {
9483
9509
  const row = db.prepare("SELECT * FROM recombine_hypotheses WHERE hypothesis_ref = ?").get(hypothesisRef);
9484
9510
  return row ?? undefined;
@@ -15887,6 +15913,8 @@ var init_config_schema = __esm(() => {
15887
15913
  }).strict().optional(),
15888
15914
  minClusterSize: exports_external.number().int().min(2).optional(),
15889
15915
  maxClustersPerRun: positiveInt.optional(),
15916
+ maxClusterSize: positiveInt.optional(),
15917
+ excludeTags: exports_external.array(exports_external.string().min(1)).optional(),
15890
15918
  relatednessSource: exports_external.enum(["tags", "graph", "both"]).optional(),
15891
15919
  confirmThreshold: exports_external.number().int().min(1).optional(),
15892
15920
  minRecurrence: exports_external.number().int().min(2).optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.29",
3
+ "version": "0.9.0-beta.30",
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": [