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

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.
@@ -134,6 +134,7 @@ function createUnknownImproveMetrics() {
134
134
  failures: 0,
135
135
  htmlErrors: 0,
136
136
  retryAttempts: 0,
137
+ nonArrayBatchFailures: 0,
137
138
  durationMs: 0,
138
139
  },
139
140
  sessionExtraction: {
@@ -440,6 +441,7 @@ function projectRunMetrics(result) {
440
441
  metrics.graphExtraction.failures += toFiniteNumber(telemetry.failureCount);
441
442
  metrics.graphExtraction.htmlErrors += toFiniteNumber(telemetry.htmlErrorCount);
442
443
  metrics.graphExtraction.retryAttempts += toFiniteNumber(telemetry.retryAttempts);
444
+ metrics.graphExtraction.nonArrayBatchFailures += toFiniteNumber(telemetry.nonArrayBatchFailures);
443
445
  }
444
446
  }
445
447
  metrics.graphExtraction.durationMs += toFiniteNumber(result.graphExtractionDurationMs);
@@ -587,6 +589,7 @@ function mergeImproveMetrics(dst, src) {
587
589
  dst.graphExtraction.truncations += src.graphExtraction.truncations;
588
590
  dst.graphExtraction.failures += src.graphExtraction.failures;
589
591
  dst.graphExtraction.htmlErrors += src.graphExtraction.htmlErrors;
592
+ dst.graphExtraction.nonArrayBatchFailures += src.graphExtraction.nonArrayBatchFailures;
590
593
  dst.graphExtraction.durationMs += src.graphExtraction.durationMs;
591
594
  dst.sessionExtraction.sessionsScanned += src.sessionExtraction.sessionsScanned;
592
595
  dst.sessionExtraction.sessionsExtracted += src.sessionExtraction.sessionsExtracted;
@@ -990,6 +993,7 @@ const INTERESTING_DELTA_PATHS = [
990
993
  "improve.graphExtraction.cacheHitRate",
991
994
  "improve.graphExtraction.failures",
992
995
  "improve.graphExtraction.htmlErrors",
996
+ "improve.graphExtraction.nonArrayBatchFailures",
993
997
  "improve.sessionExtraction.sessionsScanned",
994
998
  "improve.sessionExtraction.proposalsCreated",
995
999
  "improve.autoAccept.promoted",
@@ -1655,6 +1659,7 @@ export function renderWindowCompareMd(windows, deltas) {
1655
1659
  "improve.actions.reflect.failed",
1656
1660
  "improve.actions.distill.llmFailed",
1657
1661
  "improve.graphExtraction.failures",
1662
+ "improve.graphExtraction.nonArrayBatchFailures",
1658
1663
  "improve.wallTime.medianMs",
1659
1664
  "improve.wallTime.p95Ms",
1660
1665
  "improve.memoryInference.skippedNoFacts",
@@ -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
@@ -98,16 +98,24 @@ export function parseJsonResponse(raw) {
98
98
  * balanced `{ }` or `[ ]` structure in the text and attempts to parse that
99
99
  * substring. Returns `undefined` if no valid JSON structure is found.
100
100
  *
101
- * Non-array results are preferred: if a `{…}` object is found first, it is
102
- * returned immediately. Arrays (`[…]`) are captured as a fallback and
103
- * returned only when no object was found.
101
+ * Shape preference is controlled by {@link ParseEmbeddedJsonOptions.expect}:
102
+ * - `"any"` (default): non-array results are preferred a `{…}` object found
103
+ * first is returned immediately; arrays (`[…]`) are a fallback.
104
+ * - `"array"`: only top-level arrays are returned. The direct parse is
105
+ * accepted only if it is an array, and the scanner returns the first
106
+ * balanced `[…]` while skipping `{…}` openers entirely.
104
107
  */
105
- export function parseEmbeddedJsonResponse(raw) {
108
+ export function parseEmbeddedJsonResponse(raw, options) {
109
+ const expectArray = options?.expect === "array";
106
110
  const direct = parseJsonResponse(raw);
107
- if (direct !== undefined)
111
+ if (direct !== undefined && (!expectArray || Array.isArray(direct)))
108
112
  return direct;
109
113
  const text = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
110
114
  let arrayFallback;
115
+ // Scan only *top-level* balanced structures: once a `{…}`/`[…]` is matched we
116
+ // jump `start` past its closing bracket rather than re-scanning its interior.
117
+ // This keeps array mode from salvaging an array *nested inside* a leading
118
+ // object (e.g. the `entities` array of a bare `{entities,relations}` object).
111
119
  for (let start = 0; start < text.length; start++) {
112
120
  const opener = text[start];
113
121
  if (opener !== "{" && opener !== "[")
@@ -116,6 +124,7 @@ export function parseEmbeddedJsonResponse(raw) {
116
124
  let depth = 0;
117
125
  let inString = false;
118
126
  let escaped = false;
127
+ let end = -1;
119
128
  for (let i = start; i < text.length; i++) {
120
129
  const ch = text[i];
121
130
  if (inString) {
@@ -139,20 +148,31 @@ export function parseEmbeddedJsonResponse(raw) {
139
148
  if (ch === closer) {
140
149
  depth -= 1;
141
150
  if (depth === 0) {
142
- try {
143
- const parsed = JSON.parse(text.slice(start, i + 1));
144
- if (!Array.isArray(parsed)) {
145
- return parsed;
146
- }
147
- arrayFallback ??= parsed;
148
- break;
149
- }
150
- catch {
151
- break;
152
- }
151
+ end = i;
152
+ break;
153
153
  }
154
154
  }
155
155
  }
156
+ if (end === -1)
157
+ continue; // never balanced — let the next opener try
158
+ try {
159
+ const parsed = JSON.parse(text.slice(start, end + 1));
160
+ if (Array.isArray(parsed)) {
161
+ // First valid array wins in array mode; in "any" mode it is the
162
+ // fallback returned only if no object is found.
163
+ if (expectArray)
164
+ return parsed;
165
+ arrayFallback ??= parsed;
166
+ }
167
+ else if (!expectArray) {
168
+ return parsed;
169
+ }
170
+ // Skip past this balanced structure so we don't descend into it.
171
+ start = end;
172
+ }
173
+ catch {
174
+ // Malformed candidate — advance one char and try the next opener.
175
+ }
156
176
  }
157
177
  return arrayFallback;
158
178
  }
@@ -372,6 +372,7 @@ export async function runGraphExtractionPass(ctx) {
372
372
  failureCount: 0,
373
373
  htmlErrorCount: 0,
374
374
  retryAttempts: 0,
375
+ nonArrayBatchFailures: 0,
375
376
  };
376
377
  const canReusePreviousGraph = previousGraph.telemetry?.extractorId === extractorId;
377
378
  const runtimeTelemetry = {
@@ -625,6 +626,7 @@ export async function runGraphExtractionPass(ctx) {
625
626
  telemetry.failureCount = runtimeTelemetry.failureCount ?? 0;
626
627
  telemetry.htmlErrorCount = runtimeTelemetry.htmlErrorCount ?? 0;
627
628
  telemetry.retryAttempts = runtimeTelemetry.retryAttempts ?? 0;
629
+ telemetry.nonArrayBatchFailures = runtimeTelemetry.nonArrayBatchFailures ?? 0;
628
630
  const qualityConsidered = mergedNodes.length;
629
631
  const qualityExtracted = mergedNodes.filter((node) => node.status === "extracted" && node.entities.length > 0).length;
630
632
  const quality = computeGraphQualityTelemetry(qualityConsidered, qualityExtracted, deduped.entities.length, deduped.relations.length);
@@ -433,6 +433,18 @@ function buildBatchSystemPrompt() {
433
433
  "The array length MUST equal the number of assets provided. " +
434
434
  'Use {"entities":[],"relations":[]} for assets with no extractable graph content.');
435
435
  }
436
+ /**
437
+ * Hardened system prompt for the single batch retry (#635). Used only after a
438
+ * first response failed array salvage — leans harder on "raw array only" so a
439
+ * model that wrapped the array in prose/fences corrects itself before we pay
440
+ * the per-asset fallback.
441
+ */
442
+ function buildBatchRetrySystemPrompt() {
443
+ return (`${buildBatchSystemPrompt()} ` +
444
+ "Your previous response could NOT be parsed as a JSON array. " +
445
+ "Respond with ONLY the raw JSON array — start with '[' and end with ']'. " +
446
+ "No prose, no explanation, no markdown code fences, no preamble.");
447
+ }
436
448
  function buildBatchUserPrompt(bodies) {
437
449
  const count = bodies.length;
438
450
  const assetBlocks = bodies.map((body, i) => `${BATCH_ASSET_SEPARATOR} ${i + 1} ===\n${body.trim()}`).join("\n\n");
@@ -542,7 +554,21 @@ export async function extractGraphFromBodies(llmConfig, bodies, signal, akmConfi
542
554
  });
543
555
  if (!raw)
544
556
  return null;
545
- const parsed = parseEmbeddedJsonResponse(raw);
557
+ // Array-preferring salvage (#635): the batch contract is a top-level
558
+ // JSON array. A leading/example `{…}` object in the response must not
559
+ // mask a valid `[…]` array as a false "non-array" failure.
560
+ let parsed = parseEmbeddedJsonResponse(raw, { expect: "array" });
561
+ if (!Array.isArray(parsed)) {
562
+ // One stricter-reprompt retry before paying the per-asset fallback
563
+ // (#635). Many genuine non-array responses recover when the model is
564
+ // told explicitly to emit only the raw array.
565
+ bumpTelemetry(options.telemetry, "retryAttempts");
566
+ const retryRaw = await chatCompletion(llmConfig, [
567
+ { role: "system", content: buildBatchRetrySystemPrompt() },
568
+ { role: "user", content: userPrompt },
569
+ ], { temperature: 0, timeoutMs: llmConfig.timeoutMs, signal });
570
+ parsed = retryRaw ? parseEmbeddedJsonResponse(retryRaw, { expect: "array" }) : undefined;
571
+ }
546
572
  if (!Array.isArray(parsed)) {
547
573
  nonArrayResponse = true;
548
574
  bumpTelemetry(options.telemetry, "nonArrayBatchFailures");
@@ -552,8 +578,9 @@ export async function extractGraphFromBodies(llmConfig, bodies, signal, akmConfi
552
578
  batchState.batchingDisabled = true;
553
579
  }
554
580
  }
555
- warn(`graph extraction (batch): LLM response was not a JSON array for ${nonEmptyBodies.length} asset(s); ` +
556
- `will fall back per-asset. promptChars=${userPrompt.length}${formatContextHint(llmConfig)}`);
581
+ warn(`graph extraction (batch): LLM response was not a JSON array for ${nonEmptyBodies.length} asset(s) ` +
582
+ `even after a stricter retry; will fall back per-asset. ` +
583
+ `promptChars=${userPrompt.length}${formatContextHint(llmConfig)}`);
557
584
  return null;
558
585
  }
559
586
  return parsed;
@@ -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.45",
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": [