akm-cli 0.9.2-alpha.1 → 0.9.2-alpha.3
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/CHANGELOG.md +59 -28
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
- package/dist/commands/improve/collapse-detector.js +3 -4
- package/dist/commands/improve/extract-prompt.js +64 -22
- package/dist/commands/improve/extract.js +122 -53
- package/dist/commands/read/curate.js +43 -22
- package/dist/commands/sources/bundle-cli.js +1 -1
- package/dist/commands/sources/installed-stashes.js +67 -26
- package/dist/core/adapter/adapters/akm-adapter.js +2 -1
- package/dist/core/config/config.js +2 -6
- package/dist/core/config/schema/index-config.js +0 -27
- package/dist/indexer/index-written-assets.js +17 -9
- package/dist/indexer/indexer.js +32 -214
- package/dist/indexer/materialize-embeddings.js +155 -0
- package/dist/indexer/passes/metadata.js +263 -118
- package/dist/indexer/scan/doc-to-entry.js +0 -1
- package/dist/indexer/search/db-search.js +58 -28
- package/dist/indexer/search/fts-query.js +40 -40
- package/dist/indexer/search/ranking.js +36 -1
- package/dist/indexer/search/search-attribution.js +3 -1
- package/dist/indexer/search/search-fields.js +23 -14
- package/dist/indexer/walk/matchers.js +39 -2
- package/dist/output/text/command-format.js +3 -1
- package/dist/scripts/akm-migrate-node.js +12908 -12738
- package/dist/scripts/akm-migrate.js +12908 -12738
- package/dist/storage/repositories/index-entries-repository.js +40 -26
- package/dist/storage/repositories/index-entry-schema.js +1 -1
- package/dist/storage/repositories/index-fts-repository.js +56 -63
- package/dist/storage/repositories/index-schema.js +4 -9
- package/dist/storage/repositories/index-vec-repository.js +55 -6
- package/docs/reference/cli.md +4 -4
- package/docs/reference/configuration.md +6 -9
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,54 @@ 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.3] - 2026-08-26
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Currency in prose no longer retypes an asset as a command** (#824). The
|
|
14
|
+
smart-Markdown classifier matched `$1`/`$2`/`$3` with a trailing word
|
|
15
|
+
boundary, and that boundary sits between the `2` and the comma in `$2,000` —
|
|
16
|
+
so any note quoting a price was indexed as a `command`, its ref moved to
|
|
17
|
+
`commands/<dir>/<slug>`, and it left its own namespace. Measured on a real
|
|
18
|
+
corpus, 3 of 51 memory documents were affected, and those 3 were exactly the
|
|
19
|
+
3 whose bodies matched. `$ARGUMENTS` is unambiguous and keeps its existing
|
|
20
|
+
precedence over a directory hint; the numeric placeholders now exclude a
|
|
21
|
+
following digit (or a `.`/`,` followed by one), and where they still
|
|
22
|
+
disagree with a directory that declares a type, the declaration wins. The
|
|
23
|
+
defect is present identically in 0.9.1 — it only became visible once the
|
|
24
|
+
0.9.2-alpha.2 retrieval work let mistyped assets surface in results.
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
- Recorded the 0.9.2 retrieval measurement in
|
|
29
|
+
`docs/plans/benchmark-tuning-findings.md` §2e (#825). Retrieval-only probes
|
|
30
|
+
with no model in the loop, identical corpora, only the CLI version differing:
|
|
31
|
+
LoCoMo zero-hit 75.0% -> 0.0% and evidence recall@5 0.154 -> 0.590;
|
|
32
|
+
LongMemEval zero-hit 100% -> 0.0% and recall@5 0.000 -> 1.000.
|
|
33
|
+
|
|
34
|
+
## [0.9.2-alpha.2] - 2026-08-25
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **Retrieval:** relaxed zero-hit lexical queries centrally, stabilized
|
|
39
|
+
relaxed-retrieval quality, and preserved name quality through relaxed
|
|
40
|
+
ranking. This is the change measured in §2e above — it is what lifted the
|
|
41
|
+
retrieval ceiling that had floored memory-backed evaluation.
|
|
42
|
+
- **Indexing:** verify vec completeness before promotion; compare vec IDs as
|
|
43
|
+
exact sets; materialize vectors for targeted writes; make nested entry
|
|
44
|
+
mutation atomic; reconcile clean before final verification; restore static
|
|
45
|
+
embedding imports.
|
|
46
|
+
- **Markdown projection:** parse nested links safely, parse destination
|
|
47
|
+
phases, and project with stateful delimiters.
|
|
48
|
+
- **Sources:** reconcile local bundle updates and report incomplete filesystem
|
|
49
|
+
reconciliation.
|
|
50
|
+
- **Extract:** keep malformed model output retryable.
|
|
51
|
+
|
|
52
|
+
### Performance
|
|
53
|
+
|
|
54
|
+
- Keep targeted embedding selection narrow and preserve targeted vec
|
|
55
|
+
degradation.
|
|
56
|
+
|
|
9
57
|
## [0.9.2-alpha.1] - 2026-08-24
|
|
10
58
|
|
|
11
59
|
### Breaking changes & migration
|
|
@@ -1213,34 +1261,17 @@ See `docs/migration/v0.8-to-v0.9.md` and
|
|
|
1213
1261
|
and a real domain asset always outranks the facts. That invariant is pinned
|
|
1214
1262
|
by `tests/search-convention-fact-demotion.test.ts`, which becomes the
|
|
1215
1263
|
regression guard if a demotion contributor is ever revisited.
|
|
1216
|
-
- **
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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`.
|
|
1264
|
+
- **One progressive lexical retrieval path and searchable Markdown prose.**
|
|
1265
|
+
FTS now tokenizes Unicode letters/numbers, deduplicates and caps the query,
|
|
1266
|
+
then runs strict AND, prefix-AND, and a single OR/prefix-OR recovery only
|
|
1267
|
+
after both conjunctive forms miss. Every stage keeps the existing BM25 field
|
|
1268
|
+
weights and downstream ranker; callers do not maintain stopword lists or
|
|
1269
|
+
parallel result collections. AKM-native Markdown contributes a normalized,
|
|
1270
|
+
16,384-character body projection through the existing lowest-weight
|
|
1271
|
+
`content` field. Frontmatter, comments, fenced code, and link destinations
|
|
1272
|
+
are removed; secret/env values and raw session/checkpoint bodies never enter
|
|
1273
|
+
the projection. Structured fields remain first in the bounded embedding
|
|
1274
|
+
input, so exact names and metadata continue to dominate body-only matches.
|
|
1244
1275
|
- **`akm mv <ref> <new-name>` — rename with inbound-xref rewrite and
|
|
1245
1276
|
utility-history preservation (Experimental).** The stash conventions'
|
|
1246
1277
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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.
|
|
167
|
-
* is
|
|
168
|
-
*
|
|
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
|
|
225
|
+
return failedExtractPayload("empty_response", "LLM returned an empty response");
|
|
174
226
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
parsed
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
|
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 (
|
|
660
|
+
if (extraction.kind === "unavailable") {
|
|
603
661
|
// The seam took the fallback path (disabled / timeout / error). Return skipped.
|
|
604
|
-
return {
|
|
605
|
-
|
|
662
|
+
return unavailableExtractionResult({
|
|
663
|
+
sessionRef,
|
|
606
664
|
harness: harness.name,
|
|
607
|
-
|
|
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
|
-
|
|
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 =
|
|
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" ||
|
|
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" ||
|
|
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
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
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
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
-
?
|
|
359
|
-
:
|
|
360
|
-
const mergedHits = [
|
|
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]
|
|
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: "
|
|
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 },
|