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.
- package/dist/commands/health.js +5 -0
- package/dist/commands/improve/improve.js +1 -0
- package/dist/commands/improve/recombine.js +182 -15
- package/dist/core/config/config-schema.js +5 -1
- package/dist/core/parse.js +36 -16
- package/dist/indexer/graph/graph-extraction.js +2 -0
- package/dist/llm/graph-extract.js +30 -3
- package/dist/scripts/migrate-storage.js +1 -0
- package/package.json +1 -1
package/dist/commands/health.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// (
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
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 =
|
|
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 "
|
|
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
|
package/dist/core/parse.js
CHANGED
|
@@ -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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* returned
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": [
|