akm-cli 0.9.0-beta.43 → 0.9.0-beta.44

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.
@@ -3987,6 +3987,7 @@ async function runImprovePostLoopStage(args) {
3987
3987
  // #632 — clustering-tuning knobs. UNSET = pre-#632 behaviour.
3988
3988
  maxClusterSize: improveProfile.processes?.recombine?.maxClusterSize,
3989
3989
  excludeTags: improveProfile.processes?.recombine?.excludeTags,
3990
+ excludeEntities: improveProfile.processes?.recombine?.excludeEntities,
3990
3991
  });
3991
3992
  }
3992
3993
  catch (e) {
@@ -57,7 +57,27 @@ import { isConsolidationEligibleMemoryName } from "./consolidate.js";
57
57
  const RECOMBINE_SYSTEM_PROMPT = recombineSystemPrompt;
58
58
  const DEFAULT_MIN_CLUSTER_SIZE = 3;
59
59
  const DEFAULT_MAX_CLUSTERS_PER_RUN = 5;
60
- const DEFAULT_RELATEDNESS_SOURCE = "tags";
60
+ // #632 slots in each run's processed budget reserved for the top TAG clusters
61
+ // when entity clusters are present, so tag-only topics (a topic with a good tag
62
+ // but no extracted graph entity) are never fully starved by entity preference.
63
+ // Entities still take the rest of the budget; whichever kind is short, the other
64
+ // backfills. UNSET of entities (tags-only stash) ignores this and takes top-N tags.
65
+ const RESERVED_TAG_SLOTS = 3;
66
+ // #632 — a tag cluster larger than this is treated as an over-broad project
67
+ // mega-bucket (low-coherence, the bland signal #632 de-emphasizes), so the
68
+ // reserved tag slots prefer TIGHTER clusters at or below this size first.
69
+ const TAG_RESERVE_SOFT_CAP = 20;
70
+ // #632 — default to the UNION of tag + graph-entity relatedness, with entity
71
+ // clusters PREFERRED at selection time (see the rank in buildRelatednessClusters).
72
+ // Entity clustering surfaces coherent, subject-scoped clusters (a tool/subsystem)
73
+ // that the coarse stash-wide tag buckets miss, while tags still cover memories the
74
+ // graph has no entity for. The `entity:` vs `tag:` signature namespaces are
75
+ // independent, so a pure tag cluster's confirmation streak is only re-baselined
76
+ // when its OWN membership changes — which is exactly what the session-capture pool
77
+ // exclusion intends for the telemetry-polluted buckets (re-baselining a noisy
78
+ // cluster's streak is correct, not a regression). A stash with no extracted graph
79
+ // entities falls through to tag-only.
80
+ const DEFAULT_RELATEDNESS_SOURCE = "both";
61
81
  /** #625 — re-induction count required before a hypothesis promotes to a lesson. */
62
82
  const DEFAULT_CONFIRM_THRESHOLD = 2;
63
83
  /**
@@ -131,6 +151,76 @@ export function isJunkTag(tag) {
131
151
  return true; // short hex hashes: 002c624c, 192d
132
152
  return false;
133
153
  }
154
+ /**
155
+ * #632 — generic extraction-artefact entities the graph routinely emits: session
156
+ * bookkeeping (`session_id`, `session_checkpoint`), structured-log field names
157
+ * (`reason`, `harness`, `structured event log`), and the like. They are
158
+ * stash-wide and carry no topical signal, so an `entity:<norm>` cluster keyed on
159
+ * one is exactly the bland mega-bucket #632 aims to remove. Lowercased; matched
160
+ * against the already-normalised `entity_norm`.
161
+ */
162
+ const JUNK_ENTITY_NORMS = new Set([
163
+ "session",
164
+ "session_id",
165
+ "session_checkpoint",
166
+ "checkpoint",
167
+ "reason",
168
+ "harness",
169
+ "event",
170
+ "event log",
171
+ "structured event",
172
+ "structured event log",
173
+ "timestamp",
174
+ "metadata",
175
+ "status",
176
+ ]);
177
+ /**
178
+ * #632 — AKM session-capture telemetry memories: auto-generated session-end
179
+ * checkpoints named `<harness>-session-<YYYYMMDD>-<id>` or
180
+ * `<harness>-checkpoint-<YYYYMMDD…>-<id>`, carrying an embedded
181
+ * `akm_memory_kind: session_checkpoint` metadata block. Their bodies are
182
+ * pipeline bookkeeping, so the graph extracts their metadata FIELDS
183
+ * (`session_checkpoint`, `harness`, `buffered observations`, `tool_*_observed`,
184
+ * …) as ENTITIES — which then dominate entity clustering as bland, stash-wide
185
+ * mega-buckets (the #632 symptom, just under an `entity:` signature). They are
186
+ * session telemetry, not durable knowledge to generalize, so recombine excludes
187
+ * them from its pool. The `\d{8}` datestamp anchor is what distinguishes a
188
+ * capture name from a durable memory that merely MENTIONS session/checkpoint
189
+ * (e.g. `akm-plugins-session-end-extract-hook`, `session-checkpoint-lint-skips`),
190
+ * which stay in the pool.
191
+ */
192
+ export function isSessionCaptureMemoryName(name) {
193
+ return /-(session|checkpoint)-\d{8}/.test(name);
194
+ }
195
+ /**
196
+ * #632 — an entity carries no clustering signal (and must be skipped) when it is
197
+ * a generic extraction artefact (session / structured-log bookkeeping), a raw
198
+ * filesystem path (absolute paths the extractor lifts verbatim), or the same
199
+ * number / date / hash / version / stopword junk `isJunkTag` rejects. Mirrors
200
+ * `isJunkTag` so the graph relatedness source does not reintroduce the very
201
+ * bland buckets entity clustering is meant to replace. Unlike `excludeEntities`
202
+ * (a fixed user list), this catches the OPEN-ENDED junk without config upkeep.
203
+ */
204
+ export function isJunkEntity(entity) {
205
+ const e = entity.trim().toLowerCase();
206
+ if (e.length <= 1)
207
+ return true;
208
+ if (JUNK_ENTITY_NORMS.has(e))
209
+ return true;
210
+ if (JUNK_STOPWORD_TAGS.has(e))
211
+ return true;
212
+ if (e.includes("/") || e.includes("\\"))
213
+ return true; // raw file paths
214
+ if (/^\d+$/.test(e))
215
+ return true; // pure numbers + dates
216
+ if (/^v?\d+(?:\.\d+)+$/.test(e))
217
+ return true; // versions
218
+ if (/^v\d+$/.test(e))
219
+ return true; // v0, v2
220
+ if (/^[0-9a-f]{4,}$/.test(e) && /\d/.test(e))
221
+ return true; // short hex hashes
222
+ return false;
223
+ }
134
224
  /**
135
225
  * Build relatedness clusters from the memory pool. Clustering is driven purely
136
226
  * by shared tags / graph entities — it MUST NOT use embedding similarity, so
@@ -146,13 +236,19 @@ export function isJunkTag(tag) {
146
236
  * A cluster is a signal whose member set is >= `minClusterSize`. Overlapping
147
237
  * clusters are de-duplicated by member-set identity, and the result is RANKED
148
238
  * by member-count descending (deterministic alphabetical tiebreak). The
149
- * `maxClustersPerRun` cap is NOT applied here — call {@link capClusters} on the
150
- * result for the processed slice; the full ranked list is retained so the
151
- * cap-aware decay sweep can tell cap-displacement from corpus absence (#658).
239
+ * `maxClustersPerRun` cap is NOT applied here — call {@link selectClustersForRun}
240
+ * (entity-aware blend) or {@link capClusters} (tags-only) on the result for the
241
+ * processed slice; the full ranked list is retained so the cap-aware decay sweep
242
+ * can tell cap-displacement from corpus absence (#658).
152
243
  */
153
244
  export function buildRelatednessClusters(entries, opts) {
154
245
  // Only consolidation-eligible memories participate (exclude `.derived`).
155
- const memories = entries.filter((e) => e.entry.type === "memory" && isConsolidationEligibleMemoryName(e.entry.name));
246
+ // #632 durable memories only: exclude `.derived` (via
247
+ // `isConsolidationEligibleMemoryName`) AND session-capture telemetry dumps
248
+ // whose embedded metadata pollutes both tag and entity clustering.
249
+ const memories = entries.filter((e) => e.entry.type === "memory" &&
250
+ isConsolidationEligibleMemoryName(e.entry.name) &&
251
+ !isSessionCaptureMemoryName(e.entry.name));
156
252
  // signal -> member entries
157
253
  const groups = new Map();
158
254
  const add = (signal, entry) => {
@@ -173,9 +269,13 @@ export function buildRelatednessClusters(entries, opts) {
173
269
  const hasEntities = !!opts.entityByEntryId && opts.entityByEntryId.size > 0;
174
270
  const useGraph = (opts.relatednessSource === "graph" || opts.relatednessSource === "both") && hasEntities;
175
271
  const tagsFallback = !useTags && opts.relatednessSource === "graph" && !hasEntities;
176
- // #632 — tags excluded from tag-based clustering (applies regardless of
177
- // source). UNSET/[] leaves clustering byte-identical to the pre-#632 path.
272
+ // #632 — tags/entities excluded from clustering (applies regardless of
273
+ // source). UNSET/[] leaves tag clustering byte-identical to the pre-#632 path.
178
274
  const excludeTags = new Set(opts.excludeTags ?? []);
275
+ // `entity_norm` is always lowercased (graph-dedup.ts), so normalise the
276
+ // user-supplied exclusion list to match — `excludeEntities: ["OpenCode"]`
277
+ // should suppress the stored `opencode` entity (Reviewer A, #632).
278
+ const excludeEntities = new Set((opts.excludeEntities ?? []).map((e) => e.toLowerCase()));
179
279
  for (const entry of memories) {
180
280
  if (useTags || tagsFallback) {
181
281
  for (const tag of entry.entry.tags ?? []) {
@@ -187,8 +287,13 @@ export function buildRelatednessClusters(entries, opts) {
187
287
  }
188
288
  }
189
289
  if (useGraph && opts.entityByEntryId) {
190
- for (const ent of opts.entityByEntryId.get(entry.id) ?? [])
290
+ for (const ent of opts.entityByEntryId.get(entry.id) ?? []) {
291
+ if (excludeEntities.has(ent))
292
+ continue;
293
+ if (isJunkEntity(ent))
294
+ continue; // #632 — skip generic extraction-artefact / path entities
191
295
  add(`entity:${ent}`, entry);
296
+ }
192
297
  }
193
298
  }
194
299
  // Keep only groups at or above the minimum cluster size. #632 — when
@@ -216,12 +321,25 @@ export function buildRelatednessClusters(entries, opts) {
216
321
  seenMemberKeys.add(memberKey);
217
322
  return true;
218
323
  });
219
- // Rank largest-first (deterministic alphabetical tiebreak). The cap is applied
220
- // by the caller via {@link capClusters}, NOT here, so the FULL formed set
221
- // (every cluster that genuinely re-forms this run) stays available for the
222
- // cap-aware decay sweep — a cluster displaced by the cap must not be confused
223
- // with a cluster that vanished from the corpus (#658).
224
- clusters.sort((a, b) => b.members.length - a.members.length || a.signature.localeCompare(b.signature));
324
+ // #632 rank ENTITY clusters ahead of tag clusters, then largest-first within
325
+ // each kind (deterministic alphabetical tiebreak). A graph entity is an
326
+ // EXTRACTED SUBJECT (a tool / subsystem / component), so it is a far
327
+ // higher-signal cluster key than an auto-tokenized frontmatter tag, whose
328
+ // broadest buckets (`tag:<project>` e.g. every memory tagged `akm`) are the
329
+ // coarse, bland clusters #632 set out to kill. Largest-first ALONE let those
330
+ // tag mega-buckets fill the `maxClustersPerRun` slice every run and starve the
331
+ // coherent entity clusters this pass produces. Preferring entities keeps tag
332
+ // clustering as the fallback (a stash with no graph entities, or a topic with a
333
+ // tag but no extracted entity, still clusters) while ensuring the better signal
334
+ // wins the cap. The processed slice is chosen by the caller via
335
+ // {@link selectClustersForRun} (NOT here), so the FULL formed set stays
336
+ // available for the cap-aware decay sweep —
337
+ // a cluster displaced by the cap must not be confused with a cluster that
338
+ // vanished from the corpus (#658).
339
+ const entityRank = (sig) => (sig.startsWith("entity:") ? 0 : 1);
340
+ clusters.sort((a, b) => entityRank(a.signature) - entityRank(b.signature) ||
341
+ b.members.length - a.members.length ||
342
+ a.signature.localeCompare(b.signature));
225
343
  return clusters;
226
344
  }
227
345
  /**
@@ -235,6 +353,54 @@ export function buildRelatednessClusters(entries, opts) {
235
353
  export function capClusters(ranked, maxClustersPerRun) {
236
354
  return ranked.slice(0, Math.max(0, maxClustersPerRun));
237
355
  }
356
+ /**
357
+ * #632 — pick the per-run PROCESSED slice from the full ranked list, BLENDING
358
+ * entity and tag clusters so neither starves the other:
359
+ *
360
+ * - No entity clusters → top `maxClustersPerRun` TAG clusters (a tags-only
361
+ * stash is byte-identical to {@link capClusters}).
362
+ * - Entity clusters present → ENTITIES LEAD the budget (the higher-signal key —
363
+ * an extracted subject vs an auto-tokenized filename tag), but RESERVE up to
364
+ * `RESERVED_TAG_SLOTS` for tag clusters so tag-only topics still surface every
365
+ * run. Entities are never starved below one slot when present and the budget
366
+ * allows. Whichever kind is short, the other backfills, so the full budget is
367
+ * always used when enough clusters exist.
368
+ *
369
+ * The reserved tag slots prefer TIGHTER tag clusters (size <= `TAG_RESERVE_SOFT_CAP`,
370
+ * largest-first within that band, then the over-cap buckets): a broad
371
+ * auto-tokenized `tag:<project>` mega-bucket is exactly the bland, low-coherence
372
+ * signal #632 de-emphasizes, so the reserve should not spend its protected slots
373
+ * on the largest tags. Entities themselves stay largest-first — an entity is a
374
+ * coherent subject at any size.
375
+ *
376
+ * Entities lead the returned order. The FULL pre-cap `ranked` list (not this
377
+ * slice) still feeds the cap-aware decay sweep (#658), so a cluster left out of
378
+ * the processed slice this run is NOT decayed as if it vanished.
379
+ */
380
+ export function selectClustersForRun(ranked, maxClustersPerRun) {
381
+ const max = Math.max(0, maxClustersPerRun);
382
+ if (max === 0)
383
+ return [];
384
+ const entities = ranked.filter((c) => c.signature.startsWith("entity:"));
385
+ if (entities.length === 0)
386
+ return ranked.slice(0, max); // tags-only → top-N (capClusters parity)
387
+ const tags = ranked.filter((c) => !c.signature.startsWith("entity:"));
388
+ // Reserve up to RESERVED_TAG_SLOTS for tags, but never below one slot for the
389
+ // leading entities when the budget allows (so a small `maxClustersPerRun` does
390
+ // not silently invert the entity preference).
391
+ const reservedForTags = Math.min(tags.length, RESERVED_TAG_SLOTS, Math.max(0, max - 1));
392
+ const entityTake = Math.min(entities.length, max - reservedForTags);
393
+ const tagTake = Math.min(tags.length, max - entityTake); // tags take their reserve + any slot entities left
394
+ // Prefer tight tags (<= soft cap) over broad mega-buckets for the reserve.
395
+ const reserveTags = [...tags]
396
+ .sort((a, b) => {
397
+ const aOver = a.members.length > TAG_RESERVE_SOFT_CAP ? 1 : 0;
398
+ const bOver = b.members.length > TAG_RESERVE_SOFT_CAP ? 1 : 0;
399
+ return aOver - bOver || b.members.length - a.members.length || a.signature.localeCompare(b.signature);
400
+ })
401
+ .slice(0, tagTake);
402
+ return [...entities.slice(0, entityTake), ...reserveTags];
403
+ }
238
404
  // ── Prompt + ref derivation ───────────────────────────────────────────────────
239
405
  /** Read a memory body (frontmatter stripped) for the cluster prompt. */
240
406
  function readBody(entry) {
@@ -408,8 +574,9 @@ export async function akmRecombine(opts) {
408
574
  ...(entityByEntryId ? { entityByEntryId } : {}),
409
575
  ...(opts.maxClusterSize != null ? { maxClusterSize: opts.maxClusterSize } : {}),
410
576
  ...(opts.excludeTags ? { excludeTags: opts.excludeTags } : {}),
577
+ ...(opts.excludeEntities ? { excludeEntities: opts.excludeEntities } : {}),
411
578
  });
412
- const clusters = capClusters(rankedClusters, maxClustersPerRun);
579
+ const clusters = selectClustersForRun(rankedClusters, maxClustersPerRun);
413
580
  let clustersFormed = 0;
414
581
  let proposalsEmitted = 0;
415
582
  let lessonsPromoted = 0;
@@ -316,9 +316,13 @@ export const ImproveProcessConfigSchema = z
316
316
  // (generic project-wide tags). Default UNSET/[]. Only meaningful on
317
317
  // `recombine`.
318
318
  excludeTags: z.array(z.string().min(1)).optional(),
319
+ // #632 — recombine process: entity_norm values that must never form an
320
+ // entity cluster (user counterpart to the built-in generic-entity filter).
321
+ // Default UNSET/[]. Only meaningful on `recombine`.
322
+ excludeEntities: z.array(z.string().min(1)).optional(),
319
323
  // #609 — recombine process: relatedness signal used to form clusters
320
324
  // (tags | graph | both). Clustering is by relatedness, never embedding
321
- // similarity. Default "tags". Only meaningful on `recombine`.
325
+ // similarity. Default "both" (#632). Only meaningful on `recombine`.
322
326
  relatednessSource: z.enum(["tags", "graph", "both"]).optional(),
323
327
  // #609 — recombine process: consecutive re-inductions required before a
324
328
  // hypothesis is promoted to a lesson. Default 2. Only meaningful on
@@ -16083,6 +16083,7 @@ var init_config_schema = __esm(() => {
16083
16083
  maxClustersPerRun: positiveInt.optional(),
16084
16084
  maxClusterSize: positiveInt.optional(),
16085
16085
  excludeTags: exports_external.array(exports_external.string().min(1)).optional(),
16086
+ excludeEntities: exports_external.array(exports_external.string().min(1)).optional(),
16086
16087
  relatednessSource: exports_external.enum(["tags", "graph", "both"]).optional(),
16087
16088
  confirmThreshold: exports_external.number().int().min(1).optional(),
16088
16089
  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.43",
3
+ "version": "0.9.0-beta.44",
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": [