akm-cli 0.10.261010-beta → 0.10.261012-beta

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 (33) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/assets/prompts/distill-lesson-system.md +18 -5
  3. package/dist/assets/prompts/memory-infer-user.md +3 -1
  4. package/dist/commands/health/checks.js +10 -6
  5. package/dist/commands/improve/distill-promotion-policy.js +5 -4
  6. package/dist/commands/improve/extract.js +92 -21
  7. package/dist/commands/improve/improve.js +16 -2
  8. package/dist/commands/improve/loop-stages.js +2 -2
  9. package/dist/commands/improve/session-asset.js +2 -2
  10. package/dist/commands/improve/stage.js +3 -1
  11. package/dist/commands/read/remember-cli.js +1 -1
  12. package/dist/commands/read/show.js +4 -1
  13. package/dist/commands/remember.js +12 -7
  14. package/dist/core/adapter/adapters/akm-metadata.js +5 -14
  15. package/dist/core/asset/frontmatter.js +10 -1
  16. package/dist/core/subprocess.js +19 -7
  17. package/dist/indexer/passes/memory-inference.js +194 -19
  18. package/dist/indexer/search/db-search.js +2 -0
  19. package/dist/indexer/search/search-attribution.js +8 -3
  20. package/dist/indexer/search/search-hit-enrichers.js +16 -14
  21. package/dist/llm/memory-infer.js +3 -1
  22. package/dist/output/renderers.js +4 -4
  23. package/dist/output/text/show-format.js +1 -1
  24. package/dist/scripts/akm-migrate-node.js +7 -10
  25. package/dist/scripts/akm-migrate.js +7 -10
  26. package/docs/architecture/0.10-process-rules.md +12 -8
  27. package/docs/architecture/improvement.md +23 -0
  28. package/docs/architecture/internals/storage-locations.md +5 -3
  29. package/docs/architecture/persisted-data-compat.md +1 -0
  30. package/docs/reference/cli.md +1 -1
  31. package/docs/reference/data-and-telemetry.md +2 -1
  32. package/docs/reference/memory.md +30 -5
  33. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  import { scanEnvKeyNames } from "../../../commands/env/env.js";
5
5
  import { parseTaskSource } from "../../../tasks/source/parse-task-source.js";
6
6
  import { compileWorkflowSource, workflowStepInstructions } from "../../../workflows/compile.js";
7
- import { parseFrontmatter } from "../../asset/frontmatter.js";
7
+ import { parseFrontmatter, readFrontmatterDate } from "../../asset/frontmatter.js";
8
8
  import { parseMarkdownToc } from "../../asset/markdown.js";
9
9
  import { nonEmptyString } from "./shared.js";
10
10
  /**
@@ -125,19 +125,10 @@ export function foldRecognizedMetadata(rendererName, file) {
125
125
  const source = nonEmptyString(fm.source);
126
126
  if (source)
127
127
  hints.add(source);
128
- const fmObservedAt = nonEmptyString(fm.observed_at);
129
- if (fmObservedAt) {
130
- hints.add(`observed_at:${fmObservedAt}`);
131
- }
132
- else {
133
- try {
134
- const isoDate = file.stat().mtime.toISOString().slice(0, 10);
135
- hints.add(`observed_at:${isoDate}`);
136
- }
137
- catch {
138
- // Non-fatal: skip mtime fallback on stat error
139
- }
140
- }
128
+ // Only a stated observation date is a hint: a file's write time is not one.
129
+ const observed = readFrontmatterDate(fm, "observed");
130
+ if (observed)
131
+ hints.add(`observed:${observed}`);
141
132
  const expires = nonEmptyString(fm.expires);
142
133
  if (expires)
143
134
  hints.add(`expires:${expires}`);
@@ -11,7 +11,7 @@
11
11
  import { createHash } from "node:crypto";
12
12
  import fs from "node:fs";
13
13
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
14
- import { existingFileMode, writeFileAtomic } from "../common.js";
14
+ import { asNonEmptyString, existingFileMode, writeFileAtomic } from "../common.js";
15
15
  import { recordWrittenPath } from "../write-provenance.js";
16
16
  import { assembleAsset, assembleAssetFromString, serializeFrontmatter } from "./asset-serialize.js";
17
17
  /**
@@ -53,6 +53,15 @@ export function parseFrontmatter(raw) {
53
53
  bodyStartLine: parsedBlock.bodyStartLine,
54
54
  };
55
55
  }
56
+ /**
57
+ * Read a date-valued frontmatter key by its bare name (`observed`), falling
58
+ * back to the legacy `<name>_at` spelling (`observed_at`) that older stashes
59
+ * still carry. The new name wins when both are present. Writers write only the
60
+ * bare name.
61
+ */
62
+ export function readFrontmatterDate(fm, key) {
63
+ return asNonEmptyString(fm[key]) ?? asNonEmptyString(fm[`${key}_at`]);
64
+ }
56
65
  /**
57
66
  * Normalize YAML dates to match expected AKM frontmatter types.
58
67
  *
@@ -135,9 +135,21 @@ function removeSignalGuard() {
135
135
  * else handles the signal. A command that handles it itself (`workflow run`,
136
136
  * `improve`, task runs) unwinds through its own cleanup, which already aborts
137
137
  * its children, and must not have its handling pre-empted by a re-raise.
138
+ *
139
+ * They go in BEFORE the spawn ({@link installSignalGuard}): a signal that lands
140
+ * between the spawn and the registration would otherwise meet the default
141
+ * disposition, end akm, and leave the new group running (#1230). The listener
142
+ * runs on the event loop, so it always sees the child registered here.
138
143
  */
139
144
  function registerLiveGroupChild(child) {
140
145
  liveGroupChildren.add(child);
146
+ return () => {
147
+ liveGroupChildren.delete(child);
148
+ if (liveGroupChildren.size === 0)
149
+ removeSignalGuard();
150
+ };
151
+ }
152
+ function installSignalGuard() {
141
153
  if (!signalGuardHandlers && process.listenerCount("SIGINT") === 0 && process.listenerCount("SIGTERM") === 0) {
142
154
  const handlers = [];
143
155
  for (const signal of ["SIGINT", "SIGTERM"]) {
@@ -155,11 +167,6 @@ function registerLiveGroupChild(child) {
155
167
  }
156
168
  signalGuardHandlers = handlers;
157
169
  }
158
- return () => {
159
- liveGroupChildren.delete(child);
160
- if (liveGroupChildren.size === 0)
161
- removeSignalGuard();
162
- };
163
170
  }
164
171
  /**
165
172
  * The joined, human-readable reason ONE managed run's capture is incomplete, or
@@ -383,11 +390,17 @@ export async function runManagedSubprocess(cmd, opts) {
383
390
  stderrRead: EMPTY_READ,
384
391
  };
385
392
  }
393
+ // A termination signal to this process takes the own-group child down too.
394
+ const guardsGroup = capture && spawnsOwnProcessGroup();
395
+ if (guardsGroup)
396
+ installSignalGuard();
386
397
  let proc;
387
398
  try {
388
399
  proc = spawnFn(cmd, buildSpawnOptions(opts));
389
400
  }
390
401
  catch (err) {
402
+ if (guardsGroup && liveGroupChildren.size === 0)
403
+ removeSignalGuard();
391
404
  return {
392
405
  exitCode: null,
393
406
  stdout: "",
@@ -446,8 +459,7 @@ export async function runManagedSubprocess(cmd, opts) {
446
459
  else
447
460
  abortSignal.addEventListener("abort", onAbort, { once: true });
448
461
  }
449
- // A termination signal to this process takes the own-group child down too.
450
- const unregisterLiveChild = capture && spawnsOwnProcessGroup() ? registerLiveGroupChild({ proc, terminate: onAbort }) : () => { };
462
+ const unregisterLiveChild = guardsGroup ? registerLiveGroupChild({ proc, terminate: onAbort }) : () => { };
451
463
  // Stream-drain timeout: a short grace once nothing living owns the pipe any
452
464
  // more, or a one-hour safety bound when execution itself is unbounded.
453
465
  //
@@ -28,6 +28,12 @@
28
28
  * A pass runs iff both layers allow it. Existing inferred children are
29
29
  * NEVER deleted — the user keeps what was already produced.
30
30
  *
31
+ * Scope sync: on every run, an existing derived child (`<name>.derived.md`,
32
+ * `inferred: true`, `derivedFrom` naming its parent) whose normalized scope
33
+ * differs from its parent's gets only its scope frontmatter rewritten to the
34
+ * parent's — deterministic, no model call, body and other metadata untouched.
35
+ * See {@link syncDerivedChildScopes}.
36
+ *
31
37
  * Locked v1 contract:
32
38
  * - LLM access is exclusively via the frozen runner returned by
33
39
  * `resolveIndexPassExecution("memory", config)`.
@@ -37,12 +43,13 @@
37
43
  */
38
44
  import fs from "node:fs";
39
45
  import path from "node:path";
46
+ import { isDeepStrictEqual } from "node:util";
40
47
  import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
41
- import { assembleAsset } from "../../core/asset/asset-serialize.js";
42
- import { parseFrontmatter, parseFrontmatterBlock } from "../../core/asset/frontmatter.js";
48
+ import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
49
+ import { parseFrontmatter, parseFrontmatterBlock, readFrontmatterDate, replaceFrontmatterBlocks, } from "../../core/asset/frontmatter.js";
43
50
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
44
51
  import { bestEffort } from "../../core/best-effort.js";
45
- import { todayIso } from "../../core/common.js";
52
+ import { asNonEmptyString, existingFileMode, writeFileAtomic } from "../../core/common.js";
46
53
  import { concurrentMap } from "../../core/concurrent.js";
47
54
  import { ConfigError } from "../../core/errors.js";
48
55
  import { warn } from "../../core/warn.js";
@@ -54,8 +61,10 @@ import { isProcessEnabled } from "../../llm/feature-gate.js";
54
61
  import { resolveIndexPassExecution } from "../../llm/index-passes.js";
55
62
  import * as memoryInfer from "../../llm/memory-infer.js";
56
63
  import { computeBodyHash, getLlmCacheEntry } from "../../storage/repositories/index-llm-cache-repository.js";
64
+ import { pkgVersion } from "../../version.js";
57
65
  import { withLlmCache } from "../db/llm-cache.js";
58
66
  import { walkMarkdownFiles } from "../walk/walker.js";
67
+ import { applyProvenanceFrontmatter, SCOPE_KEYS, sameScope, scopeFromFrontmatter, } from "./metadata.js";
59
68
  /**
60
69
  * Frontmatter keys this pass cares about. Constants so a future rename only
61
70
  * needs to touch one site.
@@ -66,10 +75,11 @@ const FM_SOURCE = "source";
66
75
  const FM_CAPTURE_MODE = "captureMode";
67
76
  /**
68
77
  * Cache namespace for this pass. Bump it when the cached response semantics
69
- * change (v3: a cached row may be an explicit no-change decision); rows under an
78
+ * change (v4: body plus grounded source context; v5: `observed` in the
79
+ * source context and prompt); rows under an
70
80
  * older namespace are never read again, which is a disposable cache miss.
71
81
  */
72
- const MEMORY_INFERENCE_CACHE_VARIANT = "memory-inference-v3";
82
+ const MEMORY_INFERENCE_CACHE_VARIANT = "memory-inference-v5";
73
83
  function validateDerivedMemoryDraft(raw) {
74
84
  if (!raw || typeof raw !== "object")
75
85
  return undefined;
@@ -89,16 +99,19 @@ function validateDerivedMemoryDraft(raw) {
89
99
  function validateMemoryInferDecision(raw) {
90
100
  return memoryInfer.isMemoryInferNoChange(raw) ? memoryInfer.MEMORY_INFER_NO_CHANGE : validateDerivedMemoryDraft(raw);
91
101
  }
102
+ function readCachedMemoryDecision(db, record) {
103
+ return bestEffort(() => {
104
+ const entry = getLlmCacheEntry(db, record.filePath, computeBodyHash(record.effectiveInput), MEMORY_INFERENCE_CACHE_VARIANT);
105
+ return entry ? validateMemoryInferDecision(JSON.parse(entry.resultJson)) : undefined;
106
+ }, "memory inference cache read corrupt — fall through to recompute");
107
+ }
92
108
  function planPendingMemoryRecord(record, ctx) {
93
109
  if (ctx.signal?.aborted)
94
110
  return { kind: "aborted", record };
95
111
  if (fs.existsSync(derivedChildPath(record)))
96
112
  return { kind: "existing-child", record };
97
113
  if (ctx.db && !ctx.reEnrich) {
98
- const cached = bestEffort(() => {
99
- const entry = getLlmCacheEntry(ctx.db, record.filePath, computeBodyHash(record.body), MEMORY_INFERENCE_CACHE_VARIANT);
100
- return entry ? validateMemoryInferDecision(JSON.parse(entry.resultJson)) : undefined;
101
- }, "memory inference cache read corrupt — fall through to recompute");
114
+ const cached = readCachedMemoryDecision(ctx.db, record);
102
115
  if (cached)
103
116
  return { kind: "cache-hit", record, decision: cached };
104
117
  }
@@ -141,17 +154,17 @@ async function inferPendingMemoryRecord(plan, ctx) {
141
154
  const onRetryAttempt = () => {
142
155
  retryAttempts += 1;
143
156
  };
144
- const infer = () => compressMemoryToDerivedMemory(llmRunner, record.body, signal, featureConfig, (event) => warn(`[akm] LLM fallback for ${event.feature}: ${event.reason}`), inferTelemetry, onRetryAttempt, onNotices);
157
+ const infer = () => compressMemoryToDerivedMemory(llmRunner, record.effectiveInput, signal, featureConfig, (event) => warn(`[akm] LLM fallback for ${event.feature}: ${event.reason}`), inferTelemetry, onRetryAttempt, onNotices);
145
158
  let derived = plan.kind === "cache-hit" ? plan.decision : undefined;
146
159
  try {
147
160
  if (plan.kind === "model") {
148
161
  derived = db
149
- ? await withLlmCache(db, record.filePath, record.body, reEnrich ?? false, infer, validateMemoryInferDecision, undefined, MEMORY_INFERENCE_CACHE_VARIANT, {
162
+ ? await withLlmCache(db, record.filePath, record.effectiveInput, reEnrich ?? false, infer, validateMemoryInferDecision, undefined, MEMORY_INFERENCE_CACHE_VARIANT, {
150
163
  onCacheHit: () => {
151
164
  fromCache = true;
152
165
  },
153
166
  })
154
- : await compressMemoryToDerivedMemory(llmRunner, record.body, signal, config, (event) => warn(`[akm] LLM fallback for ${event.feature}: ${event.reason}`), inferTelemetry, onRetryAttempt, onNotices);
167
+ : await compressMemoryToDerivedMemory(llmRunner, record.effectiveInput, signal, config, (event) => warn(`[akm] LLM fallback for ${event.feature}: ${event.reason}`), inferTelemetry, onRetryAttempt, onNotices);
155
168
  }
156
169
  }
157
170
  catch (error) {
@@ -231,6 +244,7 @@ async function runMemoryInferencePassBody(ctx, provenance) {
231
244
  skippedAborted: 0,
232
245
  unaccounted: 0,
233
246
  htmlErrorCount: 0,
247
+ scopeSynced: 0,
234
248
  writtenPaths: [],
235
249
  };
236
250
  // Mutable sink threaded into compressMemoryToDerivedMemory so the per-call
@@ -277,7 +291,14 @@ async function runMemoryInferencePassBody(ctx, provenance) {
277
291
  warn(`memory inference: skipped — the default bundle at ${primary.path} is not an akm bundle`);
278
292
  return completeResult();
279
293
  }
280
- const pending = collectPendingMemories(primary.path).filter((record) => !options.candidateRefs || options.candidateRefs.has(record.ref));
294
+ const inCandidates = (ref) => !options.candidateRefs || options.candidateRefs.has(ref);
295
+ const scan = scanMemories(primary.path);
296
+ // A read-only default bundle keeps its children as they are; the
297
+ // deterministic scope rewrite runs before, and apart from, any model work.
298
+ if (primary.writable !== false && !signal?.aborted) {
299
+ result.scopeSynced = syncDerivedChildScopes(scan.scopeDrift.filter((drift) => inCandidates(drift.parentRef)));
300
+ }
301
+ const pending = scan.pending.filter((record) => inCandidates(record.ref));
281
302
  result.considered = pending.length;
282
303
  if (pending.length === 0)
283
304
  return completeResult();
@@ -379,16 +400,37 @@ async function runMemoryInferencePassBody(ctx, provenance) {
379
400
  return completeResult();
380
401
  }
381
402
  // ── Pending detection ───────────────────────────────────────────────────────
403
+ /**
404
+ * Pending parents the `minPendingCount` gate should count: those with no cached
405
+ * no-change decision for their current effective input. A decided parent stays unstamped
406
+ * by design (so an edit re-opens it), but a pass made only of cached decisions
407
+ * makes no model call. Read-only; `db` absent counts every pending parent.
408
+ */
409
+ export function countPendingMemoriesNeedingDecision(stashRoot, db) {
410
+ const pending = collectPendingMemories(stashRoot);
411
+ if (!db)
412
+ return pending.length;
413
+ return pending.filter((record) => readCachedMemoryDecision(db, record) !== memoryInfer.MEMORY_INFER_NO_CHANGE).length;
414
+ }
382
415
  /**
383
416
  * Walk `<stashRoot>/memories/` (recursively) and return every memory that
384
417
  * still needs inference. The directory may not exist on a fresh stash; that
385
418
  * is treated as "no pending memories" rather than an error.
386
419
  */
387
420
  export function collectPendingMemories(stashRoot) {
421
+ return scanMemories(stashRoot).pending;
422
+ }
423
+ /**
424
+ * One walk of `<stashRoot>/memories/`: the pending parents, and the existing
425
+ * derived children whose scope has drifted from their parent's.
426
+ */
427
+ function scanMemories(stashRoot) {
388
428
  const memoriesDir = path.join(stashRoot, "memories");
389
429
  if (!fs.existsSync(memoriesDir))
390
- return [];
430
+ return { pending: [], scopeDrift: [] };
391
431
  const out = [];
432
+ const parentScopes = new Map();
433
+ const children = [];
392
434
  const walked = walkMarkdownFiles(memoriesDir);
393
435
  if (!walked.complete) {
394
436
  warn(`memory inference: directory scan under ${memoriesDir} is incomplete — some memories may be missing`);
@@ -403,21 +445,86 @@ export function collectPendingMemories(stashRoot) {
403
445
  continue;
404
446
  }
405
447
  const parsed = parseFrontmatter(raw);
406
- if (!isPendingMemory(parsed.data, filePath))
407
- continue;
408
448
  const relName = toMemoryName(memoriesDir, filePath);
409
449
  if (!relName)
410
450
  continue;
451
+ if (relName.endsWith(".derived")) {
452
+ children.push({ filePath, name: relName, data: parsed.data });
453
+ continue;
454
+ }
455
+ parentScopes.set(relName, scopeFromFrontmatter(parsed.data));
456
+ if (!isPendingMemory(parsed.data, filePath))
457
+ continue;
411
458
  out.push({
412
459
  filePath,
413
460
  stashRoot,
414
461
  ref: conceptIdFromTypeName("memory", relName),
415
462
  data: parsed.data,
416
- body: parsed.content,
463
+ effectiveInput: memoryInferenceInput(parsed.content, parsed.data),
417
464
  name: relName,
418
465
  });
419
466
  }
420
- return out;
467
+ const scopeDrift = [];
468
+ for (const child of children) {
469
+ const parentName = child.name.slice(0, -".derived".length);
470
+ const scope = parentScopes.get(parentName);
471
+ if (!scope || !isDerivedChildOf(child.data, parentName))
472
+ continue;
473
+ if (sameScope(scopeFromFrontmatter(child.data), scope))
474
+ continue;
475
+ scopeDrift.push({
476
+ childPath: child.filePath,
477
+ parentRef: conceptIdFromTypeName("memory", parentName),
478
+ parentName,
479
+ scope,
480
+ });
481
+ }
482
+ return { pending: out, scopeDrift };
483
+ }
484
+ /** True only for a file this pass derived from `parentName`: `inferred: true` and a matching `derivedFrom`. */
485
+ function isDerivedChildOf(fm, parentName) {
486
+ if (fm[FM_INFERRED] !== true)
487
+ return false;
488
+ const derivedFrom = asNonEmptyString(fm.derivedFrom);
489
+ return derivedFrom === parentName || derivedFrom === conceptIdFromTypeName("memory", parentName);
490
+ }
491
+ /**
492
+ * Section markers of the effective input; `memory-infer-user.md` names them.
493
+ * The context comes first and is pretty-printed JSON, whose lines are never
494
+ * exactly `Body:`, so the first such line always starts the raw body.
495
+ */
496
+ const SOURCE_CONTEXT_MARKER = "Source context:";
497
+ const BODY_MARKER = "Body:";
498
+ /** The non-empty strings of a frontmatter list; anything else is no list. */
499
+ function stringList(raw) {
500
+ return Array.isArray(raw) ? raw.map(asNonEmptyString).filter((value) => value !== undefined) : [];
501
+ }
502
+ /**
503
+ * The effective input: a fixed selection of existing source fields (never
504
+ * arbitrary frontmatter or model-authored authority) as JSON, then the raw
505
+ * trimmed body. One string drives the request and every cache lookup.
506
+ */
507
+ function memoryInferenceInput(body, fm) {
508
+ const trimmedBody = body.trim();
509
+ if (!trimmedBody)
510
+ return "";
511
+ const sourceContext = {};
512
+ for (const key of ["title", "description", "evidence", "source", "observed", "created", "updated"]) {
513
+ const value = key === "observed" ? readFrontmatterDate(fm, key) : asNonEmptyString(fm[key]);
514
+ if (value)
515
+ sourceContext[key] = value;
516
+ }
517
+ for (const key of ["evidenceSources", "xrefs", "sources"]) {
518
+ const values = stringList(fm[key]);
519
+ if (values.length)
520
+ sourceContext[key] = values;
521
+ }
522
+ const entry = { type: "memory", name: "source" };
523
+ applyProvenanceFrontmatter(entry, fm);
524
+ if (entry.provenance?.sources)
525
+ sourceContext.provenance = { sources: entry.provenance.sources };
526
+ sourceContext.scope = scopeFromFrontmatter(fm);
527
+ return `${SOURCE_CONTEXT_MARKER}\n${JSON.stringify(sourceContext, null, 2)}\n\n${BODY_MARKER}\n${trimmedBody}`;
421
528
  }
422
529
  /**
423
530
  * Predicate: true when the parsed frontmatter indicates the memory has not
@@ -502,6 +609,11 @@ async function writeDerivedMemory(parent, derived) {
502
609
  }
503
610
  }
504
611
  function renderDerivedMemory(parent, derived) {
612
+ // Generation is when and by what this file was written: deterministic, never
613
+ // model-authored and never sent to the model. `updated` is that same instant
614
+ // as the child's own modification date. Neither says when the facts were
615
+ // observed or that they were verified, so no `verified` is stamped.
616
+ const generatedAt = new Date().toISOString();
505
617
  const fm = {
506
618
  [FM_INFERRED]: true,
507
619
  [FM_CAPTURE_MODE]: "background",
@@ -513,8 +625,27 @@ function renderDerivedMemory(parent, derived) {
513
625
  searchHints: derived.searchHints,
514
626
  title: derived.title,
515
627
  derivedFrom: parent.name,
516
- updated: todayIso(),
628
+ generated: { by: `akm/${pkgVersion}`, at: generatedAt },
629
+ updated: generatedAt.slice(0, 10),
517
630
  };
631
+ // The source's own explicit observation date and evidence are carried as
632
+ // written. A missing one stays missing: `updated` and mtime are not dates of
633
+ // the facts.
634
+ const observed = readFrontmatterDate(parent.data, "observed");
635
+ if (observed)
636
+ fm.observed = observed;
637
+ const evidence = asNonEmptyString(parent.data.evidence);
638
+ if (evidence)
639
+ fm.evidence = evidence;
640
+ const evidenceSources = stringList(parent.data.evidenceSources);
641
+ if (evidenceSources.length)
642
+ fm.evidenceSources = evidenceSources;
643
+ const sources = { type: "memory", name: parent.name };
644
+ applyProvenanceFrontmatter(sources, parent.data);
645
+ if (sources.provenance?.sources)
646
+ fm.provenance = { sources: sources.provenance.sources };
647
+ for (const [key, value] of Object.entries(scopeFromFrontmatter(parent.data)))
648
+ fm[`scope_${key}`] = value;
518
649
  return assembleAsset(fm, `# ${derived.title.trim()}\n\n${derived.content.trim()}\n`);
519
650
  }
520
651
  function markParentProcessed(parent) {
@@ -554,3 +685,47 @@ function markParentProcessed(parent) {
554
685
  warn(`memory inference: failed to mark parent processed ${parent.filePath}: ${err instanceof Error ? err.message : String(err)}`);
555
686
  }
556
687
  }
688
+ /**
689
+ * Rewrite only the scope frontmatter of each drifted child to its parent's
690
+ * normalized scope: the flat `scope_*` keys are set, stale ones and a legacy
691
+ * nested `scope:` removed, and every other byte — body included — kept. No
692
+ * model call. Each file is re-read and re-checked first, so a child edited
693
+ * since the walk is judged as it is now; one that no longer drifts, is no
694
+ * longer a derived child of that parent, or whose frontmatter cannot be edited
695
+ * in place is left untouched. Like {@link markParentProcessed}, this is a
696
+ * frontmatter-only exception to `writeAssetToSource`, written atomically and
697
+ * journaled so the caller indexes and commits it. Returns the rewrite count.
698
+ */
699
+ function syncDerivedChildScopes(drifts) {
700
+ let synced = 0;
701
+ const scopeKeys = ["scope", ...SCOPE_KEYS.map((key) => `scope_${key}`)];
702
+ for (const drift of drifts) {
703
+ try {
704
+ const raw = fs.readFileSync(drift.childPath, "utf8");
705
+ const data = parseFrontmatter(raw).data;
706
+ if (!isDerivedChildOf(data, drift.parentName) || sameScope(scopeFromFrontmatter(data), drift.scope))
707
+ continue;
708
+ const flat = {};
709
+ for (const key of SCOPE_KEYS)
710
+ if (drift.scope[key] !== undefined)
711
+ flat[`scope_${key}`] = drift.scope[key];
712
+ const lines = Object.keys(flat).length > 0 ? serializeFrontmatter(flat).split("\n") : [];
713
+ const next = replaceFrontmatterBlocks(raw, scopeKeys, lines);
714
+ const expected = { ...data };
715
+ for (const key of scopeKeys)
716
+ delete expected[key];
717
+ Object.assign(expected, flat);
718
+ if (next === null || !isDeepStrictEqual(parseFrontmatter(next).data, expected)) {
719
+ warn(`memory inference: scope of ${drift.childPath} differs from its parent but cannot be rewritten in place`);
720
+ continue;
721
+ }
722
+ writeFileAtomic(drift.childPath, next, existingFileMode(drift.childPath));
723
+ recordWrittenPath(drift.childPath);
724
+ synced += 1;
725
+ }
726
+ catch (err) {
727
+ warn(`memory inference: failed to sync scope of ${drift.childPath}: ${err instanceof Error ? err.message : String(err)}`);
728
+ }
729
+ }
730
+ return synced;
731
+ }
@@ -581,6 +581,8 @@ export async function buildDbHit(input) {
581
581
  type: input.entry.type,
582
582
  stashDir: entryStashDir,
583
583
  bundleId: input.bundleId,
584
+ defaultBundleId,
585
+ descriptionSource: input.entry.source,
584
586
  db: input.db,
585
587
  eligible: input.eligible,
586
588
  });
@@ -13,10 +13,15 @@ export function copySearchHitAttribution(from, to, outputDescription) {
13
13
  const memoryInference = from[ATTRIBUTION]?.memoryInference;
14
14
  if (!memoryInference)
15
15
  return;
16
- const memorySurvives = memoryInference.exposure !== "surface" ||
17
- (memoryInference.surfaceDescription !== undefined && memoryInference.surfaceDescription === outputDescription);
18
- if (memorySurvives)
16
+ if (memoryInference.exposure !== "surface") {
19
17
  attachSearchHitAttribution(to, { memoryInference });
18
+ }
19
+ else if (memoryInference.surfaceFields?.includes("description") &&
20
+ memoryInference.surfaceDescription !== undefined &&
21
+ memoryInference.surfaceDescription === outputDescription) {
22
+ // Curate emits descriptions, not tags. Carry only the field it retained.
23
+ attachSearchHitAttribution(to, { memoryInference: { ...memoryInference, surfaceFields: ["description"] } });
24
+ }
20
25
  }
21
26
  export function getSearchHitAttribution(target) {
22
27
  return target[ATTRIBUTION];
@@ -4,6 +4,7 @@
4
4
  import { displayRef } from "../../core/asset/resolve-ref.js";
5
5
  import { presentationFor } from "../../core/type-presentation.js";
6
6
  import { getDerivedForParent, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
7
+ import { extractDirTagsFromName, extractTagsFromPath } from "../passes/metadata.js";
7
8
  import { getRenderer } from "../walk/file-context.js";
8
9
  import { attachSearchHitAttribution } from "./search-attribution.js";
9
10
  /**
@@ -17,18 +18,18 @@ export async function enrichSearchHit(hit, ctx) {
17
18
  renderer?.enrichSearchHit?.(hit, ctx.stashDir);
18
19
  }
19
20
  if (ctx.type === "memory" && ctx.db)
20
- surfaceDerivedMemory(hit, ctx.db, ctx.bundleId, ctx.eligible);
21
+ surfaceDerivedMemory(hit, ctx.db, ctx);
21
22
  }
22
23
  /**
23
24
  * Phase 5A / Advantage D5 — when a parent memory has a `.derived` child
24
- * indexed (the LLM-distilled lesson surface), rewrite the parent hit to
25
- * surface the derived child's description / tags AND set `expandTo` to the
25
+ * indexed (the LLM-distilled lesson surface), fill missing parent metadata
26
+ * from the derived child's description / tags AND set `expandTo` to the
26
27
  * derived child's ref so callers can fetch it via `akm show <ref>`.
27
28
  *
28
29
  * The parent ref is preserved on the hit — only the surface text is
29
- * swapped, so links and provenance still point at the canonical parent.
30
+ * filled, so links and provenance still point at the canonical parent.
30
31
  */
31
- function surfaceDerivedMemory(hit, db, bundleId, eligible) {
32
+ function surfaceDerivedMemory(hit, db, ctx) {
32
33
  // Never recurse: a `.derived` hit is itself the child surface; leaving
33
34
  // it untouched also avoids `<parent>.derived.derived` chains.
34
35
  if (hit.name.toLowerCase().endsWith(".derived"))
@@ -37,25 +38,26 @@ function surfaceDerivedMemory(hit, db, bundleId, eligible) {
37
38
  // entry's name so we don't depend on whatever wiki/registry prefix `hit.ref`
38
39
  // carries. INTERNAL lookup key into `getDerivedForParent`: the `derived_from`
39
40
  // column stores this same conceptId grammar.
40
- const derived = getDerivedForParent(db, `memories/${hit.name}`, bundleId);
41
- if (!derived || (eligible && !eligible(derived)))
41
+ const derived = getDerivedForParent(db, `memories/${hit.name}`, ctx.bundleId);
42
+ if (!derived || (ctx.eligible && !ctx.eligible(derived)))
42
43
  return;
43
- // Swap description / tags from the derived child (SourceSearchHit carries no
44
- // `searchHints`).
44
+ // Fields fall back independently. Preserve authored qualifications and tags
45
+ // that add information beyond the generated filename/directory tokens.
45
46
  const surfaceFields = [];
46
47
  let surfaceDescription;
47
- if (typeof derived.entry.description === "string" && derived.entry.description.length > 0) {
48
+ if ((!hit.description?.trim() || ctx.descriptionSource === "filename") && derived.entry.description?.trim()) {
48
49
  hit.description = derived.entry.description;
49
50
  surfaceDescription = derived.entry.description;
50
51
  surfaceFields.push("description");
51
52
  }
52
- if (Array.isArray(derived.entry.tags) && derived.entry.tags.length > 0) {
53
+ const generatedTags = new Set([...extractTagsFromPath(`${hit.name}.md`, "."), ...extractDirTagsFromName(hit.name)]);
54
+ if (!hit.tags?.some((tag) => tag.trim() && !generatedTags.has(tag.trim().toLowerCase())) &&
55
+ derived.entry.tags?.some((tag) => tag.trim())) {
53
56
  hit.tags = derived.entry.tags;
54
57
  surfaceFields.push("tags");
55
58
  }
56
- // `expandTo` is a user-facing `akm show <ref>` target, so emit the 0.9.0
57
- // short conceptId grammar (`memories/<name>`).
58
- hit.expandTo = displayRef({ type: "memory", name: derived.entry.name });
59
+ // Retain the parent's bundle qualification so expansion resolves the same child.
60
+ hit.expandTo = displayRef({ type: "memory", name: derived.entry.name, bundleId: ctx.bundleId }, ctx.defaultBundleId);
59
61
  const childRef = getItemRefById(db, derived.id);
60
62
  if (childRef && surfaceFields.length > 0) {
61
63
  attachSearchHitAttribution(hit, {
@@ -62,7 +62,9 @@ export const DERIVED_MEMORY_JSON_SCHEMA = {
62
62
  additionalProperties: false,
63
63
  };
64
64
  /**
65
- * Compress a single memory body into one derived memory via the configured LLM.
65
+ * Compress a single memory into one derived memory via the configured LLM.
66
+ * `body` is the pass's effective input (a `Source context:` section, then the
67
+ * raw body after `Body:`), appended verbatim to the user prompt.
66
68
  *
67
69
  * Returns the draft, or {@link MEMORY_INFER_NO_CHANGE} when the model explicitly
68
70
  * decides the memory needs no derived memory. Returns `undefined` on any failure
@@ -12,7 +12,7 @@
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { listKeys as listVaultKeys } from "../commands/env/env.js";
15
- import { parseFrontmatter } from "../core/asset/frontmatter.js";
15
+ import { parseFrontmatter, readFrontmatterDate } from "../core/asset/frontmatter.js";
16
16
  import { asNonEmptyString, hasErrnoCode } from "../core/common.js";
17
17
  import { extractCommentMetadata } from "../indexer/passes/metadata.js";
18
18
  import { registerRenderer } from "../indexer/walk/file-context.js";
@@ -390,7 +390,7 @@ const taskMdRenderer = {
390
390
  // ── 8. session-md (#561) ─────────────────────────────────────────────────────
391
391
  /**
392
392
  * Renderer for the `session` asset type (#561). A session asset is generated by
393
- * the `extract` pass and carries `harness`, `started_at`/`ended_at`, `project`,
393
+ * the `extract` pass and carries `harness`, `started`/`ended`, `project`,
394
394
  * `log_path`, and `access` frontmatter plus an LLM `## Summary` / `## Key topics`
395
395
  * body. The renderer surfaces a human-readable one-liner (harness + date +
396
396
  * project) and concrete `access` instructions rather than dumping raw
@@ -404,8 +404,8 @@ const sessionMdRenderer = {
404
404
  const fm = parsed.data;
405
405
  const harness = asNonEmptyString(fm.harness);
406
406
  const project = asNonEmptyString(fm.project);
407
- const startedAt = asNonEmptyString(fm.started_at);
408
- const endedAt = asNonEmptyString(fm.ended_at);
407
+ const startedAt = readFrontmatterDate(fm, "started");
408
+ const endedAt = readFrontmatterDate(fm, "ended");
409
409
  const logPath = asNonEmptyString(fm.log_path);
410
410
  const access = asNonEmptyString(fm.access);
411
411
  const description = asNonEmptyString(fm.description);
@@ -25,7 +25,7 @@ export function formatShowPlain(r, detail) {
25
25
  lines.push(`evidence: ${String(r.evidence).replace(/\s+/g, " ")}`);
26
26
  if (Array.isArray(r.evidenceSources))
27
27
  lines.push(`evidenceSources: ${r.evidenceSources.join(", ")}`);
28
- if (r.evidence && r.provenance)
28
+ if (r.provenance)
29
29
  lines.push(`provenance: ${JSON.stringify(r.provenance)}`);
30
30
  if (r.workflowTitle)
31
31
  lines.push(`workflowTitle: ${String(r.workflowTitle)}`);
@@ -31086,7 +31086,7 @@ function formatShowPlain(r, detail) {
31086
31086
  lines.push(`evidence: ${String(r.evidence).replace(/\s+/g, " ")}`);
31087
31087
  if (Array.isArray(r.evidenceSources))
31088
31088
  lines.push(`evidenceSources: ${r.evidenceSources.join(", ")}`);
31089
- if (r.evidence && r.provenance)
31089
+ if (r.provenance)
31090
31090
  lines.push(`provenance: ${JSON.stringify(r.provenance)}`);
31091
31091
  if (r.workflowTitle)
31092
31092
  lines.push(`workflowTitle: ${String(r.workflowTitle)}`);
@@ -40808,6 +40808,9 @@ function parseFrontmatter(raw) {
40808
40808
  bodyStartLine: parsedBlock.bodyStartLine
40809
40809
  };
40810
40810
  }
40811
+ function readFrontmatterDate(fm, key) {
40812
+ return asNonEmptyString(fm[key]) ?? asNonEmptyString(fm[`${key}_at`]);
40813
+ }
40811
40814
  function normalizeYamlValues(value) {
40812
40815
  if (value instanceof Date) {
40813
40816
  const y = value.getUTCFullYear();
@@ -46030,15 +46033,9 @@ function foldRecognizedMetadata(rendererName, file) {
46030
46033
  const source = nonEmptyString2(fm.source);
46031
46034
  if (source)
46032
46035
  hints.add(source);
46033
- const fmObservedAt = nonEmptyString2(fm.observed_at);
46034
- if (fmObservedAt) {
46035
- hints.add(`observed_at:${fmObservedAt}`);
46036
- } else {
46037
- try {
46038
- const isoDate = file.stat().mtime.toISOString().slice(0, 10);
46039
- hints.add(`observed_at:${isoDate}`);
46040
- } catch {}
46041
- }
46036
+ const observed = readFrontmatterDate(fm, "observed");
46037
+ if (observed)
46038
+ hints.add(`observed:${observed}`);
46042
46039
  const expires = nonEmptyString2(fm.expires);
46043
46040
  if (expires)
46044
46041
  hints.add(`expires:${expires}`);