akm-cli 0.9.6 → 0.9.7
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 +190 -0
- package/dist/assets/hints/cli-hints-full.md +3 -3
- package/dist/assets/improve-strategies/catchup.json +40 -11
- package/dist/assets/improve-strategies/thorough.json +45 -7
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +2 -2
- package/dist/commands/agent/contribute-cli.js +11 -0
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/improve/improve-strategies.js +0 -4
- package/dist/commands/improve/memory/memory-improve.js +2 -1
- package/dist/commands/improve/preparation.js +1 -1
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/lint/base-linter.js +141 -18
- package/dist/commands/lint/index.js +17 -4
- package/dist/commands/read/curate.js +47 -0
- package/dist/commands/read/search-cli.js +24 -1
- package/dist/core/asset/asset-placement.js +13 -2
- package/dist/core/asset/frontmatter.js +116 -0
- package/dist/core/asset/memory-archive.js +97 -0
- package/dist/core/config/engine-semantics.js +0 -2
- package/dist/scripts/akm-migrate-node.js +13 -7
- package/dist/scripts/akm-migrate.js +13 -7
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -0
- package/dist/storage/repositories/index-connection.js +45 -3
- package/dist/tasks/backends/cron.js +49 -9
- package/dist/tasks/resolve-akm-bin.js +17 -2
- package/dist/tasks/scheduler-invocation.js +8 -1
- package/dist/tasks/source/parse-task-source.js +23 -9
- package/docs/reference/cli.md +5 -1
- package/package.json +1 -1
- package/dist/assets/improve-strategies/frequent.json +0 -15
- package/dist/assets/improve-strategies/memory-focus.json +0 -15
|
@@ -34,10 +34,11 @@
|
|
|
34
34
|
import fs from "node:fs";
|
|
35
35
|
import path from "node:path";
|
|
36
36
|
import { isScalar, parseDocument } from "yaml";
|
|
37
|
-
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
37
|
+
import { assetPathCandidatesForName, assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
38
38
|
import { BUNDLE_REF_RE } from "../../core/asset/asset-ref.js";
|
|
39
|
-
import { spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
|
|
39
|
+
import { removeFrontmatterListValues, spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
|
|
40
40
|
import { checkUnquotedDescriptionColon } from "../../core/asset/frontmatter-lint.js";
|
|
41
|
+
import { isArchivedRelPath } from "../../core/asset/memory-archive.js";
|
|
41
42
|
import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
|
|
42
43
|
import { localDateStamp } from "../../core/common.js";
|
|
43
44
|
import { findFenceRegions } from "./markdown-insertion.js";
|
|
@@ -191,6 +192,37 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
|
|
|
191
192
|
if (resolveRefPathInStash(relPath, refType, refName, root) !== null)
|
|
192
193
|
return true;
|
|
193
194
|
}
|
|
195
|
+
// #884: a memory pruned by `analyzeMemoryCleanup` was ARCHIVED, not deleted —
|
|
196
|
+
// its bytes and identity live on under `.akm/memory-cleanup/archive`. Inbound
|
|
197
|
+
// belief edges to it are satisfied, not dangling, so resolve the tombstone
|
|
198
|
+
// rather than reporting `missing-ref`. Checked only after every live location
|
|
199
|
+
// misses: a tombstone must never shadow a real file, and the scan then costs
|
|
200
|
+
// one directory read per root instead of one per ref.
|
|
201
|
+
//
|
|
202
|
+
// Existence ONLY. `resolveRefPathInStash` deliberately does NOT consult the
|
|
203
|
+
// archive: it hands back a path callers MUTATE (SPEC-5 `--supersedes`
|
|
204
|
+
// demotion), and writing into an archived tombstone would corrupt the audit
|
|
205
|
+
// record while leaving the live stash untouched.
|
|
206
|
+
return memoryArchiveHasRef(refType, refName, stashRoots);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* True when `(refType, refName)` names a memory that prune archived in any
|
|
210
|
+
* root. Mirrors `resolveRefPathInStash`'s candidate set so a ref that resolved
|
|
211
|
+
* through the `.derived.md` twin (#882) still resolves once archived.
|
|
212
|
+
*/
|
|
213
|
+
function memoryArchiveHasRef(refType, refName, stashRoots) {
|
|
214
|
+
if (refType !== "memory")
|
|
215
|
+
return false; // only memories are ever archived
|
|
216
|
+
const typeDir = stashDirFor(refType);
|
|
217
|
+
if (typeDir === undefined)
|
|
218
|
+
return false;
|
|
219
|
+
const candidates = assetPathCandidatesForName(refType, typeDir, refName);
|
|
220
|
+
for (const root of stashRoots) {
|
|
221
|
+
for (const candidate of candidates) {
|
|
222
|
+
if (isArchivedRelPath(candidate, root))
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
194
226
|
return false;
|
|
195
227
|
}
|
|
196
228
|
/**
|
|
@@ -198,8 +230,9 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
|
|
|
198
230
|
* the same reachability rules (in the same order) as
|
|
199
231
|
* {@link refExistsInAnyStash}, which delegates here. Returns the absolute path
|
|
200
232
|
* of the file that makes the ref "exist" — for a multi-file skill directory
|
|
201
|
-
* that is its `SKILL.md` primary
|
|
202
|
-
*
|
|
233
|
+
* that is its `SKILL.md` primary, for a `memory` ref its `.derived.md` twin
|
|
234
|
+
* when the plain `.md` is absent (#882, see `assetPathCandidatesForName`) —
|
|
235
|
+
* or `null` when the ref does not resolve in this root.
|
|
203
236
|
*
|
|
204
237
|
* Extracted for SPEC-5 (`--supersedes` demotion): write commands need the
|
|
205
238
|
* superseded asset's actual file to mutate, and forking a second resolver
|
|
@@ -207,10 +240,14 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
|
|
|
207
240
|
* (the contract pins `refToRelPath` + `refExistsInAnyStash`; this is the
|
|
208
241
|
* shared internal both build on).
|
|
209
242
|
*/
|
|
210
|
-
export function resolveRefPathInStash(relPath,
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
243
|
+
export function resolveRefPathInStash(relPath, refType, refName, root) {
|
|
244
|
+
const typeDir = stashDirFor(refType);
|
|
245
|
+
const candidates = typeDir === undefined ? [relPath] : assetPathCandidatesForName(refType, typeDir, refName);
|
|
246
|
+
for (const candidate of candidates) {
|
|
247
|
+
const absPath = path.join(root, candidate);
|
|
248
|
+
if (fs.existsSync(absPath))
|
|
249
|
+
return absPath;
|
|
250
|
+
}
|
|
214
251
|
return null;
|
|
215
252
|
}
|
|
216
253
|
/**
|
|
@@ -281,11 +318,31 @@ function scanBundleRefs(scanBody, allRoots) {
|
|
|
281
318
|
*/
|
|
282
319
|
function classifyConceptRef(rawConceptId, allRoots) {
|
|
283
320
|
const conceptId = rawConceptId.split("#", 1)[0];
|
|
284
|
-
const parts = typeNameFromConceptId(conceptId);
|
|
321
|
+
const parts = typeNameFromConceptId(conceptId) ?? legacyTypeSlugParts(conceptId);
|
|
285
322
|
if (parts === undefined)
|
|
286
323
|
return null; // foreign type / not a local asset ref
|
|
287
324
|
return localRefMissingRelPath(parts.type, parts.name, allRoots);
|
|
288
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Read-only fallback for the retired `type:slug` xref grammar (see
|
|
328
|
+
* resolve-ref.ts Q-02 — the write boundary no longer emits or accepts it).
|
|
329
|
+
* Stash content written before that retirement still carries it in frontmatter
|
|
330
|
+
* `xrefs:` lists, and `typeNameFromConceptId` (conceptId-only) returns
|
|
331
|
+
* `undefined` for it, so without this it was silently skipped rather than
|
|
332
|
+
* validated (#882). `refToRelPath`/`refExistsInAnyStash` already key off a bare
|
|
333
|
+
* `(type, slug)` pair — the same shape `type:slug` already is — so this only
|
|
334
|
+
* needs to split the token; no new resolution logic.
|
|
335
|
+
*/
|
|
336
|
+
function legacyTypeSlugParts(rawConceptId) {
|
|
337
|
+
const colon = rawConceptId.indexOf(":");
|
|
338
|
+
if (colon <= 0)
|
|
339
|
+
return undefined;
|
|
340
|
+
const type = rawConceptId.slice(0, colon);
|
|
341
|
+
const name = rawConceptId.slice(colon + 1);
|
|
342
|
+
if (!name || name.includes("/") || name.includes(":"))
|
|
343
|
+
return undefined;
|
|
344
|
+
return stashDirFor(type) === undefined ? undefined : { type, name };
|
|
345
|
+
}
|
|
289
346
|
/**
|
|
290
347
|
* Returns an array of {ref, resolvedRelPath} for every local AKM ref in the
|
|
291
348
|
* PROSE body that does not resolve to a real file under any of the provided
|
|
@@ -364,6 +421,12 @@ function dedupeMissing(rows) {
|
|
|
364
421
|
* excluded because it can point at merged-away or pruned assets.
|
|
365
422
|
*/
|
|
366
423
|
const XREF_FRONTMATTER_KEYS = ["xrefs", "supersededBy", "contradictedBy"];
|
|
424
|
+
/**
|
|
425
|
+
* The belief-graph subset of {@link XREF_FRONTMATTER_KEYS} — the only channels
|
|
426
|
+
* `--prune-dangling-edges` will repair (#884). Typed as `readonly string[]` so
|
|
427
|
+
* `.includes` accepts any xref key without a cast.
|
|
428
|
+
*/
|
|
429
|
+
const BELIEF_EDGE_KEYS = ["supersededBy", "contradictedBy"];
|
|
367
430
|
/**
|
|
368
431
|
* Return the `refs:` array from frontmatter when it is present and is an
|
|
369
432
|
* array of strings; otherwise return `null` to signal the caller should
|
|
@@ -597,12 +660,22 @@ export function runBaseChecks(ctx) {
|
|
|
597
660
|
});
|
|
598
661
|
}
|
|
599
662
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
663
|
+
/**
|
|
664
|
+
* Flush accumulated mutations to disk. Called after the fixable checks above
|
|
665
|
+
* AND again at the very end, because the `missing-ref` section below can also
|
|
666
|
+
* mutate (`--prune-dangling-edges`, #884) and used to run PAST this point —
|
|
667
|
+
* its edits were computed and then silently dropped.
|
|
668
|
+
*
|
|
669
|
+
* Mirrors the two stub-delete call sites in `commands/lint/index.ts`
|
|
670
|
+
* (`appendMemoryStubIssue`/`appendWorkflowStubIssue`), which already report
|
|
671
|
+
* a failed mutation as `fixed: "failed"` instead of throwing. Without this
|
|
672
|
+
* the sweep aborted mid-run on the first unwritable file and the caller got
|
|
673
|
+
* an exception instead of a result naming the fixes that HAD landed.
|
|
674
|
+
*/
|
|
675
|
+
const flushIfModified = () => {
|
|
676
|
+
if (!modified)
|
|
677
|
+
return;
|
|
678
|
+
modified = false;
|
|
606
679
|
try {
|
|
607
680
|
fs.writeFileSync(ctx.filePath, currentRaw, "utf8");
|
|
608
681
|
// Propagate the mutated raw back so subclasses can re-parse if needed
|
|
@@ -615,7 +688,8 @@ export function runBaseChecks(ctx) {
|
|
|
615
688
|
issue.detail = `${issue.detail} — could not write fix: ${reason}`;
|
|
616
689
|
}
|
|
617
690
|
}
|
|
618
|
-
}
|
|
691
|
+
};
|
|
692
|
+
flushIfModified();
|
|
619
693
|
// ── 3. stale-path ──────────────────────────────────────────────────────
|
|
620
694
|
// M3: checkStalePath returns all stale matches; push one issue per path.
|
|
621
695
|
// M4: Also scan ctx.frontmatter for stale paths (absolute paths in frontmatter).
|
|
@@ -636,6 +710,26 @@ export function runBaseChecks(ctx) {
|
|
|
636
710
|
}
|
|
637
711
|
}
|
|
638
712
|
// ── 4. missing-ref ─────────────────────────────────────────────────────
|
|
713
|
+
const missingRefPass = runMissingRefChecks(ctx, currentRaw, shouldRun, pendingFixes);
|
|
714
|
+
issues.push(...missingRefPass.issues);
|
|
715
|
+
if (missingRefPass.modified) {
|
|
716
|
+
currentRaw = missingRefPass.raw;
|
|
717
|
+
modified = true;
|
|
718
|
+
}
|
|
719
|
+
flushIfModified();
|
|
720
|
+
return issues;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* The `missing-ref` pass, extracted from {@link runBaseChecks} so that function
|
|
724
|
+
* stays under the src fn-size ratchet after #884 gave this pass its own
|
|
725
|
+
* (opt-in) mutation path. Pure move plus the explicit state hand-off: `raw` in,
|
|
726
|
+
* `{raw, modified}` out, `issues`/`pendingFixes` appended in place.
|
|
727
|
+
*/
|
|
728
|
+
function runMissingRefChecks(ctx, raw, shouldRun, pendingFixes) {
|
|
729
|
+
const issues = [];
|
|
730
|
+
let currentRaw = raw;
|
|
731
|
+
let modified = false;
|
|
732
|
+
// ── 4. missing-ref ─────────────────────────────────────────────────────
|
|
639
733
|
// Carve-out for assets that declare an explicit `refs:` array in
|
|
640
734
|
// frontmatter (e.g. session-checkpoint memories captured by the
|
|
641
735
|
// claude-code hook). The frontmatter array is the *authoritative*
|
|
@@ -650,7 +744,9 @@ export function runBaseChecks(ctx) {
|
|
|
650
744
|
// still run `checkMissingRefs` against the array itself to catch
|
|
651
745
|
// refs that were valid at capture time but later removed from the
|
|
652
746
|
// stash.
|
|
653
|
-
if (shouldRun("missing-ref"))
|
|
747
|
+
if (!shouldRun("missing-ref"))
|
|
748
|
+
return { issues, raw, modified };
|
|
749
|
+
{
|
|
654
750
|
const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
|
|
655
751
|
// An explicit `refs:` array is a REF LIST (each value is a whole ref —
|
|
656
752
|
// short conceptIds included); a bare body is PROSE (anchored refs only).
|
|
@@ -691,6 +787,33 @@ export function runBaseChecks(ctx) {
|
|
|
691
787
|
if (values === null)
|
|
692
788
|
continue;
|
|
693
789
|
const missingXrefs = checkMissingRefsInList(values, ctx.stashRoot, ctx.extraStashRoots);
|
|
790
|
+
// #884 opt-in repair. Scoped to the BELIEF channels only: an edge whose
|
|
791
|
+
// target has neither a file nor a prune tombstone asserts a
|
|
792
|
+
// relationship to a memory that no longer exists in any form, and
|
|
793
|
+
// carrying it forward corrupts every belief-graph read. `xrefs` is
|
|
794
|
+
// excluded — a stale xref is an ordinary broken link (the 3 skill refs
|
|
795
|
+
// in #884), and repairing it by DELETION would throw away a pointer the
|
|
796
|
+
// author may simply need to re-target.
|
|
797
|
+
const repairable = ctx.pruneDanglingEdges === true && BELIEF_EDGE_KEYS.includes(key) && missingXrefs.length > 0;
|
|
798
|
+
if (repairable) {
|
|
799
|
+
const dropped = missingXrefs.map(({ ref }) => ref);
|
|
800
|
+
const rewritten = removeFrontmatterListValues(currentRaw, key, dropped);
|
|
801
|
+
if (rewritten !== null) {
|
|
802
|
+
currentRaw = rewritten;
|
|
803
|
+
modified = true;
|
|
804
|
+
for (const { ref, resolvedRelPath } of missingXrefs) {
|
|
805
|
+
const issue = {
|
|
806
|
+
file: ctx.relPath,
|
|
807
|
+
issue: "missing-ref",
|
|
808
|
+
detail: `dangling ${key} edge dropped: ${ref} (no file and no prune tombstone at ${resolvedRelPath})`,
|
|
809
|
+
fixed: true,
|
|
810
|
+
};
|
|
811
|
+
issues.push(issue);
|
|
812
|
+
pendingFixes.push(issue);
|
|
813
|
+
}
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
694
817
|
for (const { ref, resolvedRelPath } of missingXrefs) {
|
|
695
818
|
issues.push({
|
|
696
819
|
file: ctx.relPath,
|
|
@@ -702,5 +825,5 @@ export function runBaseChecks(ctx) {
|
|
|
702
825
|
}
|
|
703
826
|
}
|
|
704
827
|
}
|
|
705
|
-
return issues;
|
|
828
|
+
return { issues, raw: currentRaw, modified };
|
|
706
829
|
}
|
|
@@ -385,8 +385,8 @@ function assertFixTargetWritable(stashRoot, sources) {
|
|
|
385
385
|
return;
|
|
386
386
|
// Same error kind and code `write-source.ts#ensureWritable` raises for the
|
|
387
387
|
// identical refusal, so a scripted caller classifies both the same way.
|
|
388
|
-
throw new UsageError(`lint
|
|
389
|
-
"Run `akm lint` without --fix to report findings, or set `writable: true` on the bundle.", "INVALID_FLAG_VALUE");
|
|
388
|
+
throw new UsageError(`lint: bundle "${stashRoot}" is configured \`writable: false\`; refusing to modify it. ` +
|
|
389
|
+
"Run `akm lint` without --fix / --prune-dangling-edges to report findings, or set `writable: true` on the bundle.", "INVALID_FLAG_VALUE");
|
|
390
390
|
}
|
|
391
391
|
/** True when the issue represents a file deletion that was successfully applied. */
|
|
392
392
|
function isFileDeletion(issue) {
|
|
@@ -523,7 +523,9 @@ export function lintAssetFile(ctx, subdir) {
|
|
|
523
523
|
*/
|
|
524
524
|
function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
525
525
|
const fix = options.fix === true;
|
|
526
|
-
|
|
526
|
+
// #884: the dangling-edge repair writes too, so it clears the same
|
|
527
|
+
// read-only gate `--fix` does.
|
|
528
|
+
if (fix || options.pruneDanglingEdges === true)
|
|
527
529
|
assertFixTargetWritable(stashRoot, sources);
|
|
528
530
|
const fixed = [];
|
|
529
531
|
const flagged = [];
|
|
@@ -627,7 +629,18 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
627
629
|
try {
|
|
628
630
|
issues = [
|
|
629
631
|
...fileIssues,
|
|
630
|
-
...lintAssetFile({
|
|
632
|
+
...lintAssetFile({
|
|
633
|
+
filePath,
|
|
634
|
+
relPath,
|
|
635
|
+
raw,
|
|
636
|
+
data,
|
|
637
|
+
body,
|
|
638
|
+
frontmatter,
|
|
639
|
+
fix,
|
|
640
|
+
pruneDanglingEdges: options.pruneDanglingEdges === true,
|
|
641
|
+
stashRoot,
|
|
642
|
+
extraStashRoots,
|
|
643
|
+
}, subdir),
|
|
631
644
|
];
|
|
632
645
|
}
|
|
633
646
|
catch (e) {
|
|
@@ -24,6 +24,7 @@ import { enqueueGraphExtraction, hasGraphData } from "../../indexer/db/graph-db.
|
|
|
24
24
|
import { copySearchHitAttribution, getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
|
|
25
25
|
import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
26
26
|
import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
|
|
27
|
+
import { estimateTokenCount } from "../../llm/embedders/remote.js";
|
|
27
28
|
import { truncateDescription } from "../../output/shapes/helpers.js";
|
|
28
29
|
import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js";
|
|
29
30
|
import { findEntryIdByRef, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
|
|
@@ -163,6 +164,52 @@ export async function curateSearchResults(query, result, limit, selectedType, ev
|
|
|
163
164
|
...(result.tip ? { tip: result.tip } : {}),
|
|
164
165
|
};
|
|
165
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Pack a curate result's stash hits into a single token-budgeted blob:
|
|
169
|
+
* resolve each hit's content via the SAME path `akm show` uses
|
|
170
|
+
* (`akmShowUnified` — this also means a `ref#fragment` hit packs just the
|
|
171
|
+
* matched section), then greedily accumulate hits, in the ranking order
|
|
172
|
+
* `curateSearchResults` already produced, until the next hit would exceed
|
|
173
|
+
* `budgetTokens`.
|
|
174
|
+
*
|
|
175
|
+
* Registry hits are never packed — only `CuratedStashItem`s (locked
|
|
176
|
+
* contract, AGENTS.md: registry results stay separate/opt-in).
|
|
177
|
+
*
|
|
178
|
+
* Truncation policy: drop whole hits from the tail of the ranked list first.
|
|
179
|
+
* The only exception is a single high-rank hit that alone exceeds the
|
|
180
|
+
* budget — that one hit is truncated to fit rather than dropping everything.
|
|
181
|
+
*/
|
|
182
|
+
export async function packCuratedHits(result, budgetTokens) {
|
|
183
|
+
const stashItems = result.items.filter((item) => item.source === "local");
|
|
184
|
+
const packed = [];
|
|
185
|
+
let used = 0;
|
|
186
|
+
for (const item of stashItems) {
|
|
187
|
+
let shown;
|
|
188
|
+
try {
|
|
189
|
+
shown = await akmShowUnified({ ref: item.ref, skipLogging: true });
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const content = shown.content ?? shown.template ?? shown.prompt ?? "";
|
|
195
|
+
const tokens = estimateTokenCount(content);
|
|
196
|
+
if (used + tokens <= budgetTokens) {
|
|
197
|
+
packed.push({ ref: item.ref, tokens, content });
|
|
198
|
+
used += tokens;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (packed.length === 0) {
|
|
202
|
+
const remaining = budgetTokens - used;
|
|
203
|
+
if (remaining > 0) {
|
|
204
|
+
const truncated = content.slice(0, remaining * 4);
|
|
205
|
+
packed.push({ ref: item.ref, tokens: estimateTokenCount(truncated), content: truncated });
|
|
206
|
+
used += estimateTokenCount(truncated);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
return { query: result.query, budget: budgetTokens, tokens: used, items: packed };
|
|
212
|
+
}
|
|
166
213
|
async function enrichCuratedStashHit(query, hit, supportRefs, selectedRefs, eventSource) {
|
|
167
214
|
let shown;
|
|
168
215
|
try {
|
|
@@ -20,7 +20,8 @@ import { parseMetaRef } from "../../core/asset/stash-meta.js";
|
|
|
20
20
|
import { UsageError } from "../../core/errors.js";
|
|
21
21
|
import { resolveUsageEventSource } from "../../indexer/usage/usage-events.js";
|
|
22
22
|
import { getOutputMode } from "../../output/context.js";
|
|
23
|
-
import {
|
|
23
|
+
import { deliverRendered } from "../../output/html-render.js";
|
|
24
|
+
import { akmCurate, packCuratedHits } from "./curate.js";
|
|
24
25
|
import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./search.js";
|
|
25
26
|
import { akmShowUnified } from "./show.js";
|
|
26
27
|
/**
|
|
@@ -153,6 +154,15 @@ export const curateCommand = defineJsonCommand({
|
|
|
153
154
|
},
|
|
154
155
|
limit: { type: "string", description: "Maximum number of curated results", default: "4" },
|
|
155
156
|
from: { type: "string", description: "Search source (local|registry|all)", default: "local" },
|
|
157
|
+
pack: {
|
|
158
|
+
type: "string",
|
|
159
|
+
description: "Pack the ranked stash hits' full content into a single token-budgeted blob instead of returning refs " +
|
|
160
|
+
"to follow up on individually — value is the max token budget, e.g. --pack 4000 (~4 chars/token, same " +
|
|
161
|
+
"estimator as embedding). Content is resolved the same way `akm show` resolves it, so a ref#fragment " +
|
|
162
|
+
"hit packs just that section. Registry hits (--from registry|all) are never packed. Not to be confused " +
|
|
163
|
+
"with a workflow asset's own `budget` field (a run-cost cap) — this is a context-size target for this " +
|
|
164
|
+
"one curate call.",
|
|
165
|
+
},
|
|
156
166
|
// Declared as the POSITIVE name with `default: true` — see the
|
|
157
167
|
// `project-context` comment on `searchCommand` above for why a flag NAME
|
|
158
168
|
// must never start with `no-`.
|
|
@@ -175,6 +185,7 @@ export const curateCommand = defineJsonCommand({
|
|
|
175
185
|
const source = parseSearchSource(args.from ?? "local");
|
|
176
186
|
const skipLogging = args["track-usage"] === false;
|
|
177
187
|
const outputMode = getOutputMode();
|
|
188
|
+
const packBudget = parsePositiveIntFlag(args.pack ?? undefined, "--pack");
|
|
178
189
|
const curated = await akmCurate({
|
|
179
190
|
query: args.query,
|
|
180
191
|
type,
|
|
@@ -184,9 +195,21 @@ export const curateCommand = defineJsonCommand({
|
|
|
184
195
|
eventSource: resolveUsageEventSource(),
|
|
185
196
|
attributionProjection: outputMode.shape === "agent" ? "agent" : outputMode.detail,
|
|
186
197
|
});
|
|
198
|
+
if (packBudget !== undefined) {
|
|
199
|
+
const packed = await packCuratedHits(curated, packBudget);
|
|
200
|
+
deliverRendered(outputMode.format === "text" ? formatCuratePackText(packed) : JSON.stringify(packed.items, null, 2), outputMode.outputPath);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
187
203
|
output("curate", curated);
|
|
188
204
|
},
|
|
189
205
|
});
|
|
206
|
+
/** Human-readable rendering for `akm curate --pack`: concatenated content per hit under a `## <ref>` header. */
|
|
207
|
+
function formatCuratePackText(packed) {
|
|
208
|
+
if (packed.items.length === 0) {
|
|
209
|
+
return `No packed content for "${packed.query}" (budget ${packed.budget} tokens).`;
|
|
210
|
+
}
|
|
211
|
+
return packed.items.map((item) => `## ${item.ref}\n\n${item.content}`).join("\n\n");
|
|
212
|
+
}
|
|
190
213
|
/**
|
|
191
214
|
* Reject `--scope` (either spelling) on `akm show` (E-3). `--scope` was
|
|
192
215
|
* removed in favor of `--filter` (R-047, guardrail 6 — no alias, must keep
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
|
-
import { SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js";
|
|
28
|
+
import { DERIVED_SUFFIX, SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js";
|
|
29
29
|
function toPosix(input) {
|
|
30
30
|
return input.replace(/\\/g, "/");
|
|
31
31
|
}
|
|
@@ -247,10 +247,21 @@ export function assetPathForName(assetType, typeRoot, name) {
|
|
|
247
247
|
* "default" alias is genuinely dual-owned: both `<dir>/.env` and
|
|
248
248
|
* `<dir>/default.env` derive the same canonical name (`toCanonicalName`
|
|
249
249
|
* above), so a physical-owner lookup must consider both without reading
|
|
250
|
-
* either file.
|
|
250
|
+
* either file. `memory` has a second, analogous duality (#882): a ref to
|
|
251
|
+
* `<name>` may own either `<name>.md` or the LLM-inferred `<name>.derived.md`
|
|
252
|
+
* twin — `.derived` is a provenance marker on the SAME identity, not part of
|
|
253
|
+
* the name (see `resolveParentRef`/`isDerivedMemory` in
|
|
254
|
+
* `commands/improve/memory/derived-ref.ts`, and the belief-edge identity
|
|
255
|
+
* channel's own `memory:<name>.derived` refs). The plain `.md` file wins when
|
|
256
|
+
* both exist, so it stays `primary` — first in the returned list — and every
|
|
257
|
+
* caller here already prefers the first candidate that exists on disk. Every
|
|
258
|
+
* other placement type has exactly one inverse spelling.
|
|
251
259
|
*/
|
|
252
260
|
export function assetPathCandidatesForName(assetType, typeRoot, name) {
|
|
253
261
|
const primary = assetPathForName(assetType, typeRoot, name);
|
|
262
|
+
if (assetType === "memory" && !name.endsWith(DERIVED_SUFFIX)) {
|
|
263
|
+
return [primary, assetPathForName(assetType, typeRoot, `${name}${DERIVED_SUFFIX}`)];
|
|
264
|
+
}
|
|
254
265
|
if (assetType !== "env")
|
|
255
266
|
return [primary];
|
|
256
267
|
const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined;
|
|
@@ -207,6 +207,122 @@ export function spliceFrontmatterLine(raw, line) {
|
|
|
207
207
|
lines.splice(closeIdx, 0, line);
|
|
208
208
|
return lines.join("\n");
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Strip one layer of matching quotes — frontmatter list items are often quoted
|
|
212
|
+
* refs. Written as an explicit char compare rather than a backreference regex
|
|
213
|
+
* on purpose: `scripts/lint-repository-sql.ts`'s comment/string stripper has no
|
|
214
|
+
* regex-literal awareness, so a literal holding an ODD number of quote
|
|
215
|
+
* characters desyncs its state machine and corrupts every match after it.
|
|
216
|
+
*/
|
|
217
|
+
function unquote(value) {
|
|
218
|
+
const trimmed = value.trim();
|
|
219
|
+
if (trimmed.length < 2)
|
|
220
|
+
return trimmed;
|
|
221
|
+
const first = trimmed[0];
|
|
222
|
+
if ((first === '"' || first === "'") && trimmed[trimmed.length - 1] === first)
|
|
223
|
+
return trimmed.slice(1, -1);
|
|
224
|
+
return trimmed;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Remove specific VALUES from one frontmatter list key, preserving every other
|
|
228
|
+
* byte — the counterpart to {@link spliceFrontmatterLine} for the
|
|
229
|
+
* `akm lint --prune-dangling-edges` repair (#884).
|
|
230
|
+
*
|
|
231
|
+
* Handles the three spellings a belief channel appears in: a block sequence
|
|
232
|
+
* (`contradictedBy:\n - a`), an inline flow (`contradictedBy: [a, b]`), and a
|
|
233
|
+
* bare scalar (`contradictedBy: a`). When every value under the key is removed
|
|
234
|
+
* the key itself goes too — an empty `contradictedBy: []` is not the same
|
|
235
|
+
* assertion as no edge at all.
|
|
236
|
+
*
|
|
237
|
+
* Returns the rewritten source, or `null` when `raw` has no well-formed
|
|
238
|
+
* frontmatter block or nothing matched, so the caller can leave the file
|
|
239
|
+
* untouched and report the finding unfixed. Deliberately source-preserving:
|
|
240
|
+
* these are user-authored memories, and a repair must not silently reformat
|
|
241
|
+
* the frontmatter it was not asked to touch.
|
|
242
|
+
*/
|
|
243
|
+
export function removeFrontmatterListValues(raw, key, values) {
|
|
244
|
+
const remove = new Set(values.map((v) => unquote(v)));
|
|
245
|
+
if (remove.size === 0)
|
|
246
|
+
return null;
|
|
247
|
+
const lines = raw.split(/\r?\n/);
|
|
248
|
+
if (lines[0]?.trim() !== "---")
|
|
249
|
+
return null;
|
|
250
|
+
const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
|
|
251
|
+
if (closeIdx === -1)
|
|
252
|
+
return null;
|
|
253
|
+
const out = [];
|
|
254
|
+
let changed = false;
|
|
255
|
+
let index = 0;
|
|
256
|
+
while (index < lines.length) {
|
|
257
|
+
const line = lines[index];
|
|
258
|
+
if (index === 0 || index >= closeIdx) {
|
|
259
|
+
out.push(line);
|
|
260
|
+
index += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
264
|
+
if (kv === null || kv[1] !== key) {
|
|
265
|
+
out.push(line);
|
|
266
|
+
index += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const rest = kv[2].trim();
|
|
270
|
+
// Inline flow: contradictedBy: [a, b]
|
|
271
|
+
const flow = rest.match(/^\[(.*)\]$/);
|
|
272
|
+
if (flow !== null) {
|
|
273
|
+
const kept = flow[1]
|
|
274
|
+
.split(",")
|
|
275
|
+
.map((item) => item.trim())
|
|
276
|
+
.filter(Boolean)
|
|
277
|
+
.filter((item) => !remove.has(unquote(item)));
|
|
278
|
+
const original = flow[1].split(",").filter((s) => s.trim().length > 0).length;
|
|
279
|
+
if (kept.length !== original) {
|
|
280
|
+
changed = true;
|
|
281
|
+
if (kept.length > 0)
|
|
282
|
+
out.push(`${key}: [${kept.join(", ")}]`);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
out.push(line);
|
|
286
|
+
}
|
|
287
|
+
index += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
// Bare scalar: contradictedBy: a
|
|
291
|
+
if (rest !== "") {
|
|
292
|
+
if (remove.has(unquote(rest)))
|
|
293
|
+
changed = true;
|
|
294
|
+
else
|
|
295
|
+
out.push(line);
|
|
296
|
+
index += 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Block sequence: the key line, then ` - value` items.
|
|
300
|
+
const header = line;
|
|
301
|
+
const items = [];
|
|
302
|
+
let cursor = index + 1;
|
|
303
|
+
while (cursor < closeIdx) {
|
|
304
|
+
const itemMatch = lines[cursor].match(/^\s+-\s*(.*)$/);
|
|
305
|
+
if (itemMatch === null)
|
|
306
|
+
break;
|
|
307
|
+
items.push(lines[cursor]);
|
|
308
|
+
cursor += 1;
|
|
309
|
+
}
|
|
310
|
+
const kept = items.filter((item) => !remove.has(unquote(item.replace(/^\s*-\s*/, ""))));
|
|
311
|
+
if (kept.length !== items.length) {
|
|
312
|
+
changed = true;
|
|
313
|
+
if (kept.length > 0) {
|
|
314
|
+
out.push(header);
|
|
315
|
+
out.push(...kept);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
out.push(header);
|
|
320
|
+
out.push(...items);
|
|
321
|
+
}
|
|
322
|
+
index = cursor;
|
|
323
|
+
}
|
|
324
|
+
return changed ? out.join("\n") : null;
|
|
325
|
+
}
|
|
210
326
|
/**
|
|
211
327
|
* Parse a YAML scalar value (string, boolean, or number).
|
|
212
328
|
*
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* The memory-cleanup archive as a REF-RESOLUTION surface (#884).
|
|
6
|
+
*
|
|
7
|
+
* `analyzeMemoryCleanup`'s prune (`commands/improve/memory/memory-improve.ts`
|
|
8
|
+
* `#archiveMemory`) does not delete a memory: it `rename`s the file under
|
|
9
|
+
* `.akm/memory-cleanup/archive/<stamp>-<ref>/<originalPath>` and writes a
|
|
10
|
+
* sibling `cleanup.md` audit asset carrying `ref` / `originalPath` /
|
|
11
|
+
* `archivedPath`. The bytes and the identity both survive — but the ref stops
|
|
12
|
+
* resolving at its ORIGINAL location, so every inbound belief edge
|
|
13
|
+
* (`contradictedBy` / `supersededBy`) pointing at the pruned memory becomes a
|
|
14
|
+
* `missing-ref` the moment #882 made those channels validatable.
|
|
15
|
+
*
|
|
16
|
+
* That is the #884 defect, and the archive already holds everything needed to
|
|
17
|
+
* fix it: the audit record IS a tombstone. This module reads those tombstones
|
|
18
|
+
* so ref resolution can answer "archived" instead of "missing". Resolution
|
|
19
|
+
* stays non-destructive — pruning never rewrites an unrelated memory's
|
|
20
|
+
* frontmatter, and the contradiction an edge records is preserved rather than
|
|
21
|
+
* erased (the concern #884 raised against a bare edge-scrub).
|
|
22
|
+
*
|
|
23
|
+
* A ref whose target has NO tombstone and no file is genuinely dangling — it
|
|
24
|
+
* was removed by something other than prune (a hand `git rm`, an older
|
|
25
|
+
* release). Those stay reported; clearing them mutates user data and so is
|
|
26
|
+
* gated behind `akm lint --prune-dangling-edges`.
|
|
27
|
+
*/
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
31
|
+
/** Stash-relative root the prune path archives into. Must match `memory-improve.ts#createArchiveDir`. */
|
|
32
|
+
export const MEMORY_ARCHIVE_REL = ".akm/memory-cleanup/archive";
|
|
33
|
+
/** Filename of the per-archive audit asset written alongside the archived memory. */
|
|
34
|
+
const AUDIT_FILENAME = "cleanup.md";
|
|
35
|
+
/**
|
|
36
|
+
* Cache keyed by stash root. A lint sweep resolves thousands of refs against a
|
|
37
|
+
* directory that only the prune path ever writes, so the scan runs once per
|
|
38
|
+
* root instead of once per missing ref.
|
|
39
|
+
*/
|
|
40
|
+
const cache = new Map();
|
|
41
|
+
/** @internal Drop the memoized scans — process-global state needs a reset seam for tests (#785). */
|
|
42
|
+
export function resetMemoryArchiveCache() {
|
|
43
|
+
cache.clear();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Every stash-relative `originalPath` archived under `root`, i.e. the set of
|
|
47
|
+
* paths that USED to hold a memory and now hold a tombstone instead.
|
|
48
|
+
*
|
|
49
|
+
* Unreadable or malformed audit records are skipped rather than thrown: a
|
|
50
|
+
* corrupt tombstone must degrade to "this ref is missing" (the pre-#884
|
|
51
|
+
* answer), never break the whole lint sweep.
|
|
52
|
+
*/
|
|
53
|
+
export function archivedOriginalPaths(root) {
|
|
54
|
+
const cached = cache.get(root);
|
|
55
|
+
if (cached !== undefined)
|
|
56
|
+
return cached;
|
|
57
|
+
const paths = new Set();
|
|
58
|
+
const archiveRoot = path.join(root, MEMORY_ARCHIVE_REL);
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = fs.readdirSync(archiveRoot, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
cache.set(root, paths); // no archive dir — nothing was ever pruned here
|
|
65
|
+
return paths;
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (!entry.isDirectory())
|
|
69
|
+
continue;
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(path.join(archiveRoot, entry.name, AUDIT_FILENAME), "utf8");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let originalPath;
|
|
78
|
+
try {
|
|
79
|
+
originalPath = parseFrontmatter(raw).data.originalPath;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (typeof originalPath === "string" && originalPath.trim().length > 0) {
|
|
85
|
+
paths.add(originalPath.trim().replace(/\\/g, "/"));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
cache.set(root, paths);
|
|
89
|
+
return paths;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* True when `relPath` (stash-relative, POSIX) names a memory that prune
|
|
93
|
+
* archived — the ref resolves to a tombstone rather than to nothing.
|
|
94
|
+
*/
|
|
95
|
+
export function isArchivedRelPath(relPath, root) {
|
|
96
|
+
return archivedOriginalPaths(root).has(relPath.replace(/\\/g, "/"));
|
|
97
|
+
}
|