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
|
|
116
|
-
|
|
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
|
-
|
|
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.
|
|
364
|
-
//
|
|
365
|
-
//
|
|
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
|
|
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`.
|
package/dist/core/state-db.js
CHANGED
|
@@ -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.
|
|
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": [
|