akm-cli 0.9.0-beta.44 → 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",
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.44",
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": [