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.
- package/CHANGELOG.md +101 -0
- package/dist/assets/prompts/distill-lesson-system.md +18 -5
- package/dist/assets/prompts/memory-infer-user.md +3 -1
- package/dist/commands/health/checks.js +10 -6
- package/dist/commands/improve/distill-promotion-policy.js +5 -4
- package/dist/commands/improve/extract.js +92 -21
- package/dist/commands/improve/improve.js +16 -2
- package/dist/commands/improve/loop-stages.js +2 -2
- package/dist/commands/improve/session-asset.js +2 -2
- package/dist/commands/improve/stage.js +3 -1
- package/dist/commands/read/remember-cli.js +1 -1
- package/dist/commands/read/show.js +4 -1
- package/dist/commands/remember.js +12 -7
- package/dist/core/adapter/adapters/akm-metadata.js +5 -14
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/subprocess.js +19 -7
- package/dist/indexer/passes/memory-inference.js +194 -19
- package/dist/indexer/search/db-search.js +2 -0
- package/dist/indexer/search/search-attribution.js +8 -3
- package/dist/indexer/search/search-hit-enrichers.js +16 -14
- package/dist/llm/memory-infer.js +3 -1
- package/dist/output/renderers.js +4 -4
- package/dist/output/text/show-format.js +1 -1
- package/dist/scripts/akm-migrate-node.js +7 -10
- package/dist/scripts/akm-migrate.js +7 -10
- package/docs/architecture/0.10-process-rules.md +12 -8
- package/docs/architecture/improvement.md +23 -0
- package/docs/architecture/internals/storage-locations.md +5 -3
- package/docs/architecture/persisted-data-compat.md +1 -0
- package/docs/reference/cli.md +1 -1
- package/docs/reference/data-and-telemetry.md +2 -1
- package/docs/reference/memory.md +30 -5
- 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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
*
|
package/dist/core/subprocess.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 (
|
|
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-
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
463
|
+
effectiveInput: memoryInferenceInput(parsed.content, parsed.data),
|
|
417
464
|
name: relName,
|
|
418
465
|
});
|
|
419
466
|
}
|
|
420
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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),
|
|
25
|
-
*
|
|
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
|
-
*
|
|
30
|
+
* filled, so links and provenance still point at the canonical parent.
|
|
30
31
|
*/
|
|
31
|
-
function surfaceDerivedMemory(hit, db,
|
|
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
|
-
//
|
|
44
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
57
|
-
|
|
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, {
|
package/dist/llm/memory-infer.js
CHANGED
|
@@ -62,7 +62,9 @@ export const DERIVED_MEMORY_JSON_SCHEMA = {
|
|
|
62
62
|
additionalProperties: false,
|
|
63
63
|
};
|
|
64
64
|
/**
|
|
65
|
-
* Compress a single memory
|
|
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
|
package/dist/output/renderers.js
CHANGED
|
@@ -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`, `
|
|
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 =
|
|
408
|
-
const endedAt =
|
|
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.
|
|
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.
|
|
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
|
|
46034
|
-
if (
|
|
46035
|
-
hints.add(`
|
|
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}`);
|