akm-cli 0.9.2-alpha.1 → 0.9.2-alpha.2

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +15 -28
  2. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  3. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  4. package/dist/commands/improve/collapse-detector.js +3 -4
  5. package/dist/commands/improve/extract-prompt.js +64 -22
  6. package/dist/commands/improve/extract.js +122 -53
  7. package/dist/commands/read/curate.js +43 -22
  8. package/dist/commands/sources/bundle-cli.js +1 -1
  9. package/dist/commands/sources/installed-stashes.js +67 -26
  10. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  11. package/dist/core/config/config.js +2 -6
  12. package/dist/core/config/schema/index-config.js +0 -27
  13. package/dist/indexer/index-written-assets.js +17 -9
  14. package/dist/indexer/indexer.js +32 -214
  15. package/dist/indexer/materialize-embeddings.js +155 -0
  16. package/dist/indexer/passes/metadata.js +263 -118
  17. package/dist/indexer/scan/doc-to-entry.js +0 -1
  18. package/dist/indexer/search/db-search.js +58 -28
  19. package/dist/indexer/search/fts-query.js +40 -40
  20. package/dist/indexer/search/ranking.js +36 -1
  21. package/dist/indexer/search/search-attribution.js +3 -1
  22. package/dist/indexer/search/search-fields.js +23 -14
  23. package/dist/output/text/command-format.js +3 -1
  24. package/dist/scripts/akm-migrate-node.js +12892 -12731
  25. package/dist/scripts/akm-migrate.js +12892 -12731
  26. package/dist/storage/repositories/index-entries-repository.js +40 -26
  27. package/dist/storage/repositories/index-entry-schema.js +1 -1
  28. package/dist/storage/repositories/index-fts-repository.js +56 -63
  29. package/dist/storage/repositories/index-schema.js +4 -9
  30. package/dist/storage/repositories/index-vec-repository.js +55 -6
  31. package/docs/reference/cli.md +4 -4
  32. package/docs/reference/configuration.md +6 -9
  33. package/package.json +1 -1
  34. package/schemas/akm-config.json +0 -8
package/CHANGELOG.md CHANGED
@@ -6,6 +6,10 @@ 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.2-alpha.2] - 2026-08-25
10
+
11
+ - TODO
12
+
9
13
  ## [0.9.2-alpha.1] - 2026-08-24
10
14
 
11
15
  ### Breaking changes & migration
@@ -1213,34 +1217,17 @@ See `docs/migration/v0.8-to-v0.9.md` and
1213
1217
  and a real domain asset always outranks the facts. That invariant is pinned
1214
1218
  by `tests/search-convention-fact-demotion.test.ts`, which becomes the
1215
1219
  regression guard if a demotion contributor is ever revisited.
1216
- - **Config-gated indexing of the self-situating body opening
1217
- `index.indexBodyOpening` (default `false`).** Body prose is not indexed
1218
- (the FTS `content` column carries only TOC headings and parameters), which
1219
- is why the stash conventions route orientation into
1220
- `description:`/`when_to_use:`. With the new flag enabled, the metadata pass
1221
- captures the first prose paragraph of each markdown asset body — skipping
1222
- headings (ATX and setext), fenced code blocks, thematic breaks, and a
1223
- leading nested frontmatter block (only when its content is actually
1224
- frontmatter-shaped: prose wrapped in decorative `---` lines is captured,
1225
- not discarded); capped at 280 chars with word-boundary truncation and a
1226
- trailing ellipsis into `entry.bodyOpening`, which folds into the
1227
- lowest-weight `content` FTS column (bm25 weight 1.0, so a name match always
1228
- outranks a body-opening-only match) and into the search/embedding text.
1229
- Secret and env files are never read for it, and session-kind memories
1230
- (`akm_memory_kind` in outer or nested inner frontmatter) are excluded —
1231
- their bodies are raw transcripts. Both indexing walks and write-path
1232
- indexing honor the flag (the metadata pass reads the user config directly).
1233
- With the flag absent or `false`, entries and search fields stay
1234
- byte-identical to before. **Costs of toggling (either direction):** indexed
1235
- text changes, so collapse-detector canary recall baselines shift — re-mint
1236
- via `akm improve canary --refresh` — and embeddings are NOT regenerated for
1237
- entries that already have one, while incremental runs re-extract only
1238
- changed files. Run `akm index --full` after toggling: it re-extracts every
1239
- entry and wipes embeddings so they rebuild from the new text; until then
1240
- `akm index` warns that the flag differs from the state the index was built
1241
- with. The conventions' `description:`/`when_to_use:` orientation routing
1242
- remains primary — this flag makes body openings additionally pay retrieval
1243
- rent, it does not replace structured metadata. See `docs/configuration.md`.
1220
+ - **One progressive lexical retrieval path and searchable Markdown prose.**
1221
+ FTS now tokenizes Unicode letters/numbers, deduplicates and caps the query,
1222
+ then runs strict AND, prefix-AND, and a single OR/prefix-OR recovery only
1223
+ after both conjunctive forms miss. Every stage keeps the existing BM25 field
1224
+ weights and downstream ranker; callers do not maintain stopword lists or
1225
+ parallel result collections. AKM-native Markdown contributes a normalized,
1226
+ 16,384-character body projection through the existing lowest-weight
1227
+ `content` field. Frontmatter, comments, fenced code, and link destinations
1228
+ are removed; secret/env values and raw session/checkpoint bodies never enter
1229
+ the projection. Structured fields remain first in the bounded embedding
1230
+ input, so exact names and metadata continue to dominate body-only matches.
1244
1231
  - **`akm mv <ref> <new-name>` — rename with inbound-xref rewrite and
1245
1232
  utility-history preservation (Experimental).** The stash conventions'
1246
1233
  forced-rename procedure ("grep and fix inbound xrefs in the same pass") was
@@ -73,10 +73,9 @@ xrefs:
73
73
  ## Self-situating headers and canonical naming
74
74
 
75
75
  Put the one-line orientation in `description:` and the trigger conditions in
76
- `when_to_use:` — those are indexed fields; body prose is not indexed by
77
- default (only headings reach the index; the opt-in `index.indexBodyOpening`
78
- flag adds just the first body paragraph, at the lowest weight —
79
- `description:`/`when_to_use:` remain the primary orientation channel). Then
76
+ `when_to_use:` — those remain the higher-weight indexed fields. Bounded body
77
+ prose is indexed in the lowest-weight `content` field, so
78
+ `description:`/`when_to_use:` remain the primary orientation channel. Then
80
79
  open the body with a plain title plus a one-line
81
80
  orientation naming what it is, its scope/domain, and its key entities in
82
81
  canonical spelling (`Postgres`, `OAuth`, `TLS`, `Acme`) — the entity/relation
@@ -86,9 +86,7 @@ cross-project reuse.
86
86
  (`acme` vs `acme-corp`) so the prefix does not fragment. Keep the domain
87
87
  vocabulary in `facts/conventions/domains`.
88
88
  - **Off-axis facets go in `tags:`, not a bare field.** The indexed FTS fields are
89
- name, description, tags, hints, and content (headings onlybody prose is
90
- not indexed by default; the opt-in `index.indexBodyOpening` flag adds just
91
- the first body paragraph, at the lowest weight) — there is **no `project`
89
+ name, description, tags, hints, and bounded low-weight body content there is **no `project`
92
90
  field**, so `project: projectA` in
93
91
  frontmatter is invisible to search. Put the off-axis facet in `tags` instead
94
92
  (a project-scoped memory adds `tags: [auth]`; a domain-scoped asset genuinely
@@ -163,10 +163,9 @@ export function refreshCanarySet(stateDb, indexDb, cfg) {
163
163
  * Name-free content fingerprint text for entropy metrics. The indexed
164
164
  * search_text EMBEDS the (unique) entry name, which would pin the
165
165
  * distinct-content ratio at 1.0 forever; convergence shows up in the
166
- * description/tags/heading fields, so those are what get hashed. (The raw body
167
- * is not in the index at all search_text covers metadata + TOC headings —
168
- * so v1 entropy is measured over the searchable surface, which is also what
169
- * generic merged assets converge on.)
166
+ * description/tags/heading fields, so those are what get hashed. Bounded body
167
+ * content is searchable but deliberately excluded from this fingerprint: the
168
+ * detector measures the structured surface generic merged assets converge on.
170
169
  */
171
170
  function contentFingerprint(entry) {
172
171
  const parts = [entry.description ?? "", (entry.tags ?? []).join(" "), (entry.toc ?? []).map((h) => h.text).join(" ")];
@@ -15,6 +15,7 @@
15
15
  * the model emits get dropped before we parse.
16
16
  */
17
17
  import promptTemplate from "../../assets/prompts/extract-session.md" with { type: "text" };
18
+ import { escapeJsonStringControls, stripCodeFences, stripThinkBlocks } from "../../core/parse.js";
18
19
  const EXTRACT_CANDIDATE_NAME_PATTERN = "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)?$";
19
20
  const EXTRACT_CANDIDATE_NAME_RE = new RegExp(EXTRACT_CANDIDATE_NAME_PATTERN);
20
21
  /**
@@ -163,6 +164,57 @@ export function buildExtractPrompt(input) {
163
164
  .replace("{{STANDARDS}}", standards)
164
165
  .replace("{{TRANSCRIPT}}", `${TRANSCRIPT_FENCE_BEGIN}\n${formatTranscript(input.events)}\n${TRANSCRIPT_FENCE_END}`);
165
166
  }
167
+ function failedExtractPayload(code, message) {
168
+ return { candidates: [], rationale_if_empty: message, parseFailure: { code, message } };
169
+ }
170
+ function parseFirstJsonObject(stdout) {
171
+ const text = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(stdout)));
172
+ try {
173
+ return { objectFound: text.startsWith("{"), value: JSON.parse(text) };
174
+ }
175
+ catch {
176
+ // Continue with the first object embedded in prose. Once an opening brace
177
+ // is found, never descend into a nested object if that outer object is
178
+ // malformed or truncated: doing so can turn a broken candidate into a
179
+ // superficially valid top-level payload.
180
+ }
181
+ const start = text.indexOf("{");
182
+ if (start < 0)
183
+ return { objectFound: false };
184
+ let depth = 0;
185
+ let inString = false;
186
+ let escaped = false;
187
+ for (let index = start; index < text.length; index++) {
188
+ const char = text[index];
189
+ if (inString) {
190
+ if (escaped)
191
+ escaped = false;
192
+ else if (char === "\\")
193
+ escaped = true;
194
+ else if (char === '"')
195
+ inString = false;
196
+ continue;
197
+ }
198
+ if (char === '"') {
199
+ inString = true;
200
+ continue;
201
+ }
202
+ if (char === "{")
203
+ depth += 1;
204
+ else if (char === "}") {
205
+ depth -= 1;
206
+ if (depth !== 0)
207
+ continue;
208
+ try {
209
+ return { objectFound: true, value: JSON.parse(text.slice(start, index + 1)) };
210
+ }
211
+ catch {
212
+ return { objectFound: true };
213
+ }
214
+ }
215
+ }
216
+ return { objectFound: true };
217
+ }
166
218
  /**
167
219
  * Parse the LLM's JSON response into a structured {@link ExtractPayload}.
168
220
  * Defensive — drops candidates that violate the shape rather than failing
@@ -170,32 +222,22 @@ export function buildExtractPrompt(input) {
170
222
  */
171
223
  export function parseExtractPayload(stdout) {
172
224
  if (!stdout || stdout.trim().length === 0) {
173
- return { candidates: [], rationale_if_empty: "LLM returned empty response" };
225
+ return failedExtractPayload("empty_response", "LLM returned an empty response");
174
226
  }
175
- let parsed;
176
- try {
177
- parsed = JSON.parse(stdout);
227
+ const parsed = parseFirstJsonObject(stdout);
228
+ if (parsed.value === undefined) {
229
+ return parsed.objectFound
230
+ ? failedExtractPayload("invalid_json_object", "JSON object was found but could not be parsed")
231
+ : failedExtractPayload("no_json_object", "LLM response: no JSON object found");
178
232
  }
179
- catch {
180
- // Tolerate prose preamble/postamble by extracting the first balanced
181
- // top-level JSON object.
182
- const start = stdout.indexOf("{");
183
- const end = stdout.lastIndexOf("}");
184
- if (start === -1 || end <= start) {
185
- return { candidates: [], rationale_if_empty: `LLM response was not parseable JSON` };
186
- }
187
- try {
188
- parsed = JSON.parse(stdout.slice(start, end + 1));
189
- }
190
- catch {
191
- return { candidates: [], rationale_if_empty: `LLM response was not parseable JSON` };
192
- }
233
+ if (!parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
234
+ return failedExtractPayload("invalid_payload", "LLM response JSON was not an object");
193
235
  }
194
- if (!parsed || typeof parsed !== "object") {
195
- return { candidates: [], rationale_if_empty: "LLM response was not an object" };
236
+ const obj = parsed.value;
237
+ if (!Array.isArray(obj.candidates)) {
238
+ return failedExtractPayload("invalid_payload", "LLM response JSON did not contain a candidates array");
196
239
  }
197
- const obj = parsed;
198
- const rawCandidates = Array.isArray(obj.candidates) ? obj.candidates : [];
240
+ const rawCandidates = obj.candidates;
199
241
  const candidates = [];
200
242
  for (const raw of rawCandidates) {
201
243
  if (!raw || typeof raw !== "object")
@@ -32,12 +32,14 @@ import { ConfigError, UsageError } from "../../core/errors.js";
32
32
  import { appendEvent } from "../../core/events.js";
33
33
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync, } from "../../core/file-lock.js";
34
34
  import { tryAcquireMaintenanceBarrier } from "../../core/maintenance-barrier.js";
35
+ import { redactErrorBody } from "../../core/redaction.js";
35
36
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
36
37
  import { resolveTypeConventions, typeConventionRef } from "../../core/standards/resolve-type-conventions.js";
37
38
  import { getStateDbPath, openStateDatabase } from "../../core/state-db.js";
39
+ import { runStructured } from "../../core/structured.js";
38
40
  import { repairTruncatedDescription } from "../../core/text-truncation.js";
39
41
  import { DURATION_UNITS, parseDuration } from "../../core/time.js";
40
- import { warn } from "../../core/warn.js";
42
+ import { warn, warnVerbose } from "../../core/warn.js";
41
43
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
42
44
  import { disposeLoweredExecutionDispatchLease, } from "../../integrations/agent/execution-lowering.js";
43
45
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
@@ -48,7 +50,7 @@ import { getExtractedSessionsMap, getLastExtractRunAt, shouldSkipAlreadyExtracte
48
50
  import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
49
51
  import { isProposalSkipped } from "../proposal/repository.js";
50
52
  import { resolveImproveLlmExecution } from "./execution.js";
51
- import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload } from "./extract-prompt.js";
53
+ import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload, } from "./extract-prompt.js";
52
54
  import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
53
55
  import { emitProposal } from "./proposal-envelope.js";
54
56
  import { createRunContext, resolveRunStashDir } from "./run-context.js";
@@ -507,45 +509,101 @@ function planExtractSessions(args) {
507
509
  }
508
510
  return { plans, deferredCandidates: [] };
509
511
  }
510
- /**
511
- * The bounded per-session extraction LLM call. Routes the already-resolved
512
- * symbolic runner through `callStructured` under the `session_extraction`
513
- * gate. Invalid configuration escapes before session/proposal state is
514
- * persisted. Returns the seam result plus the `llmRaw`
515
- * side-channel value that distinguishes fallback-took-over from a
516
- * genuinely-empty response.
517
- */
512
+ const EXTRACT_LLM_UNAVAILABLE = Symbol("extract-llm-unavailable");
518
513
  async function runSessionExtractionLlmCall(args) {
519
514
  const { config, llmRunner, lease, chat, prompt, timeoutMs, signal, onNotices } = args;
520
- let llmRaw = "";
521
- const llmResult = await callStructured({
522
- feature: "session_extraction",
523
- akmConfig: config,
524
- runner: llmRunner,
525
- lease,
526
- messages: [{ role: "user", content: prompt }],
527
- request: {
528
- timeoutMs,
529
- responseSchema: EXTRACT_JSON_SCHEMA,
530
- ...(signal ? { signal } : {}),
531
- ...(chat ? { chat } : {}),
532
- },
533
- onNotices,
534
- parse: (raw) => {
535
- llmRaw = raw ?? "";
536
- return llmRaw;
537
- },
538
- // A transport throw takes the "" fallback with llmRaw left unset —
539
- // the same skipped path the gated-fn throw produced before.
540
- onError: () => "",
541
- fallback: "",
542
- });
543
- return { llmResult, llmRaw };
515
+ try {
516
+ const result = await runStructured({
517
+ dispatch: async (feedback) => {
518
+ const content = feedback ? `${prompt}\n\n## Corrective output instruction\n\n${feedback}` : prompt;
519
+ const dispatched = await callStructured({
520
+ feature: "session_extraction",
521
+ akmConfig: config,
522
+ runner: llmRunner,
523
+ lease,
524
+ messages: [{ role: "user", content }],
525
+ request: {
526
+ timeoutMs,
527
+ responseSchema: EXTRACT_JSON_SCHEMA,
528
+ ...(signal ? { signal } : {}),
529
+ ...(chat ? { chat } : {}),
530
+ },
531
+ onNotices,
532
+ parse: (raw) => ({ kind: "response", raw: raw ?? "" }),
533
+ onError: () => ({ kind: "unavailable" }),
534
+ fallback: { kind: "unavailable" },
535
+ });
536
+ if (dispatched.kind === "unavailable")
537
+ throw EXTRACT_LLM_UNAVAILABLE;
538
+ return dispatched.raw;
539
+ },
540
+ parse: (raw) => {
541
+ const payload = parseExtractPayload(raw);
542
+ return payload.parseFailure ? undefined : payload;
543
+ },
544
+ validate: (payload) => ({ ok: true, value: payload }),
545
+ maxAttempts: llmRunner.connection.supportsJsonSchema === true ? 1 : 2,
546
+ buildFeedback: () => "Your previous response did not contain a valid extraction payload. Respond with ONLY a JSON object matching the requested schema, with a candidates array and no prose or code fences.",
547
+ });
548
+ if (result.ok)
549
+ return { kind: "success", payload: result.value, attempts: result.attempts };
550
+ const payload = parseExtractPayload(result.raw);
551
+ return {
552
+ kind: "malformed",
553
+ raw: result.raw,
554
+ attempts: result.attempts,
555
+ failure: payload.parseFailure ??
556
+ { code: "invalid_payload", message: result.errors.join("; ") },
557
+ };
558
+ }
559
+ catch (err) {
560
+ if (err === EXTRACT_LLM_UNAVAILABLE)
561
+ return { kind: "unavailable" };
562
+ throw err;
563
+ }
544
564
  }
545
565
  function extractNoticeFields(getNotices) {
546
566
  const notices = getNotices();
547
567
  return notices.length > 0 ? { notices } : {};
548
568
  }
569
+ function extractPreFilterStats(filtered) {
570
+ return {
571
+ inputCount: filtered.stats.inputCount,
572
+ outputCount: filtered.stats.outputCount,
573
+ truncatedCount: filtered.stats.truncatedCount,
574
+ };
575
+ }
576
+ function malformedExtractionResult(args) {
577
+ const { extraction, sessionRef, harness, preFilter, contentHash, notices } = args;
578
+ const diagnostic = `malformed_model_output: ${extraction.failure.message}; attempts=${extraction.attempts}; responseLength=${extraction.raw.length}; responseSha256=${sha256Hex(extraction.raw)}`;
579
+ warnVerbose(`[extract] malformed model output for session ${sessionRef.sessionId}: ${redactErrorBody(extraction.raw)}`);
580
+ return {
581
+ sessionId: sessionRef.sessionId,
582
+ harness,
583
+ candidateCount: 0,
584
+ proposalIds: [],
585
+ preFilter,
586
+ warnings: [diagnostic],
587
+ skipped: true,
588
+ skipReason: "malformed_model_output",
589
+ contentHash,
590
+ ...notices,
591
+ };
592
+ }
593
+ function unavailableExtractionResult(args) {
594
+ return {
595
+ sessionId: args.sessionRef.sessionId,
596
+ harness: args.harness,
597
+ candidateCount: 0,
598
+ proposalIds: [],
599
+ preFilter: args.preFilter,
600
+ warnings: ["session_extraction feature returned empty (disabled / timeout / error)"],
601
+ skipped: true,
602
+ skipReason: "llm_unavailable",
603
+ contentHash: args.contentHash,
604
+ ...args.notices,
605
+ };
606
+ }
549
607
  async function processSession(runCtx, session) {
550
608
  const { harness, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, sessionIndexing, signal, standardsContext, } = runCtx;
551
609
  const { sessionRef, gate } = session;
@@ -589,7 +647,7 @@ async function processSession(runCtx, session) {
589
647
  }
590
648
  return {};
591
649
  };
592
- const { llmResult, llmRaw } = await runSessionExtractionLlmCall({
650
+ const extraction = await runSessionExtractionLlmCall({
593
651
  config,
594
652
  llmRunner,
595
653
  lease,
@@ -599,26 +657,27 @@ async function processSession(runCtx, session) {
599
657
  signal,
600
658
  onNotices,
601
659
  });
602
- if (llmResult === "" && !llmRaw) {
660
+ if (extraction.kind === "unavailable") {
603
661
  // The seam took the fallback path (disabled / timeout / error). Return skipped.
604
- return {
605
- sessionId: sessionRef.sessionId,
662
+ return unavailableExtractionResult({
663
+ sessionRef,
606
664
  harness: harness.name,
607
- candidateCount: 0,
608
- proposalIds: [],
609
- preFilter: {
610
- inputCount: filtered.stats.inputCount,
611
- outputCount: filtered.stats.outputCount,
612
- truncatedCount: filtered.stats.truncatedCount,
613
- },
614
- warnings: ["session_extraction feature returned empty (disabled / timeout / error)"],
615
- skipped: true,
616
- skipReason: "llm_unavailable",
665
+ preFilter: extractPreFilterStats(filtered),
617
666
  contentHash,
618
- ...extractNoticeFields(getNotices),
619
- };
667
+ notices: extractNoticeFields(getNotices),
668
+ });
669
+ }
670
+ if (extraction.kind === "malformed") {
671
+ return malformedExtractionResult({
672
+ extraction,
673
+ sessionRef,
674
+ harness: harness.name,
675
+ preFilter: extractPreFilterStats(filtered),
676
+ contentHash,
677
+ notices: extractNoticeFields(getNotices),
678
+ });
620
679
  }
621
- const payload = parseExtractPayload(llmRaw);
680
+ const { payload } = extraction;
622
681
  const proposalIds = [];
623
682
  // Provenance refs are added only after the cited session asset exists.
624
683
  const sessionAsset = await maybeWriteSessionAsset();
@@ -632,6 +691,7 @@ async function processSession(runCtx, session) {
632
691
  harness: harness.name,
633
692
  sourceRun,
634
693
  rationale: payload.rationale_if_empty,
694
+ repairAttempts: extraction.attempts - 1,
635
695
  preFilterInput: filtered.stats.inputCount,
636
696
  preFilterOutput: filtered.stats.outputCount,
637
697
  },
@@ -705,6 +765,7 @@ async function processSession(runCtx, session) {
705
765
  proposalCount: proposalIds.length,
706
766
  preFilterInput: filtered.stats.inputCount,
707
767
  preFilterOutput: filtered.stats.outputCount,
768
+ repairAttempts: extraction.attempts - 1,
708
769
  },
709
770
  }, eventsCtx);
710
771
  return {
@@ -733,7 +794,9 @@ function recordExtractSessionOutcome(args) {
733
794
  return;
734
795
  try {
735
796
  const outcome = result.skipped
736
- ? result.skipReason === "read_failed" || result.skipReason === "exception"
797
+ ? result.skipReason === "read_failed" ||
798
+ result.skipReason === "exception" ||
799
+ result.skipReason === "malformed_model_output"
737
800
  ? "failed"
738
801
  : "skipped"
739
802
  : result.candidateCount === 0
@@ -749,7 +812,9 @@ function recordExtractSessionOutcome(args) {
749
812
  proposalCount: result.proposalIds.length,
750
813
  rationale: result.rationaleIfEmpty ?? null,
751
814
  sourceRun,
752
- contentHash: result.skipReason === "llm_unavailable" || result.skipReason === "triaged_out"
815
+ contentHash: result.skipReason === "llm_unavailable" ||
816
+ result.skipReason === "triaged_out" ||
817
+ result.skipReason === "malformed_model_output"
753
818
  ? null
754
819
  : (result.contentHash ?? null),
755
820
  metadata: {
@@ -908,6 +973,10 @@ async function runExtractSessionLoop(args) {
908
973
  sessionRef: summary,
909
974
  gate: executionGate,
910
975
  });
976
+ if (result.skipReason === "malformed_model_output") {
977
+ for (const warning of result.warnings)
978
+ topLevelWarnings.push(`session ${summary.sessionId}: ${warning}`);
979
+ }
911
980
  accountExtractSessionResult(result, triage.enabled, output);
912
981
  recordExtractSessionOutcome({
913
982
  stateDb,
@@ -307,16 +307,12 @@ export function deriveCurateFallbackQueries(query) {
307
307
  return tokens;
308
308
  }
309
309
  export function mergeCurateSearchResponses(base, extras) {
310
- // The base (full-query) ranking is the relevance signal keyword fallback
311
- // searches exist only to ADD recall when that ranking is thin, never to
312
- // re-rank it. So we PRESERVE base order and APPEND fallback-only hits below
313
- // it. A single-token fallback match on an exact title/path normalizes to a
314
- // high FTS score, but those scores are not comparable to the full-query
315
- // (hybrid) scores; re-sorting the union by raw score (the prior behaviour)
316
- // let that keyword junk leapfrog the contextually-relevant full-query hits.
317
- // Dup refs (present in both base and a fallback) keep their base POSITION but
318
- // take the MAX score, since matching both the full query and a key term is a
319
- // stronger relevance signal for the downstream score floor.
310
+ // The base (full-query) ranking is authoritative by default; keyword
311
+ // fallbacks add recall without comparing their raw scores to the full-query
312
+ // score scale. Base order is preserved and fallback-only hits append below,
313
+ // except for the narrow weak-provenance promotion documented below. Duplicate
314
+ // refs keep their base position but take the maximum score for the downstream
315
+ // relevance floor.
320
316
  const bestExtraStashScore = new Map();
321
317
  for (const result of extras) {
322
318
  for (const hit of result.hits.filter((entry) => entry.type !== "registry")) {
@@ -342,22 +338,39 @@ export function mergeCurateSearchResponses(base, extras) {
342
338
  extraOnly.set(hit.ref, hit);
343
339
  }
344
340
  }
345
- // Fallback-only hits must rank BELOW every full-query hit through the rest of
346
- // the pipeline. The downstream selector (`selectCuratedStashHits`) RE-SORTS by
347
- // score and derives its relevance floor from the top score, so preserving
348
- // order here is not enough — a single-token FTS match (normalized ~0.9) would
349
- // otherwise become the leader and evict the contextual full-query memories.
350
- // We therefore restamp fallback-only scores into a band strictly below the
351
- // minimum base score (keeping their own relative order). When there are no
352
- // base hits, fallback IS the result, so scores are kept as-is.
341
+ // Fallback-only hits ordinarily stay below every full-query hit through the
342
+ // downstream selector, which re-sorts by score and derives its relevance
343
+ // floor from the leader. Restamping them below the minimum base score keeps a
344
+ // single-token score from leapfrogging contextual results. The one exception
345
+ // is a strong name match repairing a lexically weak base, selected below.
353
346
  const sortedExtra = [...extraOnly.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
347
+ const lexicalAttribution = (hit) => getSearchHitAttribution(hit)?.lexical;
348
+ // The full-query base remains authoritative unless its lexical provenance is
349
+ // itself weak: relaxed recovery, or no hit whose name covers the query. In
350
+ // that case one fallback discovery with strong name coverage may lead. This
351
+ // preserves contextual base ranking while allowing measured recall to repair
352
+ // a thin match without comparing unrelated raw FTS score scales.
353
+ const weakLexicalBase = baseStash.length > 0 &&
354
+ baseStash.every((hit) => {
355
+ const lexical = lexicalAttribution(hit);
356
+ return lexical !== undefined && (lexical.execution === "relaxed" || lexical.nameMatchTier < 2);
357
+ });
358
+ const promotedExtraIndex = weakLexicalBase
359
+ ? sortedExtra.findIndex((hit) => (lexicalAttribution(hit)?.nameMatchTier ?? 0) >= 2)
360
+ : -1;
361
+ const promotedExtra = promotedExtraIndex >= 0 ? sortedExtra[promotedExtraIndex] : undefined;
362
+ const remainingExtra = promotedExtraIndex >= 0 ? sortedExtra.filter((_, index) => index !== promotedExtraIndex) : sortedExtra;
354
363
  const minBaseScore = baseStash.length
355
364
  ? Math.min(...baseStash.map((hit) => hit.score ?? 0))
356
365
  : Number.POSITIVE_INFINITY;
357
366
  const cappedExtra = baseStash.length
358
- ? sortedExtra.map((hit, i) => ({ ...hit, score: minBaseScore - 1e-6 * (i + 1) }))
359
- : sortedExtra;
360
- const mergedHits = [...baseStash, ...cappedExtra];
367
+ ? remainingExtra.map((hit, i) => ({ ...hit, score: minBaseScore - 1e-6 * (i + 1) }))
368
+ : remainingExtra;
369
+ const mergedHits = [
370
+ ...(promotedExtra ? [{ ...promotedExtra, score: minBaseScore }] : []),
371
+ ...baseStash,
372
+ ...cappedExtra,
373
+ ];
361
374
  // Registry hits are supplemental fill — same rule: base first (max score on
362
375
  // dups), then fallback-only registry hits appended by score.
363
376
  const bestExtraRegScore = new Map();
@@ -585,8 +598,16 @@ function collapseCurateFamilies(query, hits) {
585
598
  if (!representative)
586
599
  continue;
587
600
  collapsedFamilies.push(representative);
588
- const supportCandidates = [group.root, ...group.references].filter((entry) => {
601
+ const supportCandidates = [group.root, ...group.references]
602
+ .filter((entry) => {
589
603
  return entry !== undefined && entry.hit.ref !== representative.hit.ref;
604
+ })
605
+ .sort((a, b) => {
606
+ if (a === group.root)
607
+ return -1;
608
+ if (b === group.root)
609
+ return 1;
610
+ return a.hit.ref.localeCompare(b.hit.ref);
590
611
  });
591
612
  for (const support of supportCandidates) {
592
613
  appendCurateSupportRef(supportRefsByRef, representative.hit.ref, {
@@ -118,7 +118,7 @@ const removeCommand = defineJsonCommand({
118
118
  },
119
119
  });
120
120
  const updateCommand = defineJsonCommand({
121
- meta: { name: "update", description: "Stage, audit, and update one or all remote bundles" },
121
+ meta: { name: "update", description: "Refresh one or all configured bundles and reconcile their index" },
122
122
  args: {
123
123
  target: { type: "positional", description: "Bundle to update (id or ref)", required: false },
124
124
  all: { type: "boolean", description: "Update all configured bundles and report each outcome", default: false },