akm-cli 0.7.5 → 0.8.0-rc.6
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/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
- package/README.md +20 -4
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli.js +1995 -551
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +1531 -0
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +990 -75
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +5 -23
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +400 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +77 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-profiles.js +146 -0
- package/dist/commands/improve-result-file.js +103 -0
- package/dist/commands/improve.js +2175 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/index.js +183 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/vault-key-rules.js +139 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal.js +66 -12
- package/dist/commands/propose.js +86 -31
- package/dist/commands/reflect.js +1119 -73
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember.js +69 -6
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +144 -25
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +438 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/commands/vault.js +130 -77
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +7 -0
- package/dist/core/asset-registry.js +7 -16
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +22 -0
- package/dist/core/common.js +157 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +625 -0
- package/dist/core/config-schema.js +501 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +327 -987
- package/dist/core/errors.js +40 -19
- package/dist/core/events.js +91 -138
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +3 -6
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +326 -14
- package/dist/core/proposal-quality-validators.js +364 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +498 -42
- package/dist/core/state-db.js +927 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/warn.js +62 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +152 -253
- package/dist/indexer/db.js +933 -103
- package/dist/indexer/ensure-index.js +64 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -124
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +506 -291
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +148 -160
- package/dist/indexer/memory-inference.js +99 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +255 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +5 -16
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +150 -74
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +118 -23
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +68 -0
- package/dist/integrations/session-logs/providers/claude-code.js +59 -0
- package/dist/integrations/session-logs/providers/opencode.js +55 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +72 -124
- package/dist/llm/embedder.js +3 -19
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +3 -0
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +89 -48
- package/dist/llm/graph-extract.js +676 -70
- package/dist/llm/index-passes.js +9 -23
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +281 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +5 -318
- package/dist/output/context.js +3 -0
- package/dist/output/renderers.js +223 -256
- package/dist/output/shapes.js +150 -105
- package/dist/output/text.js +318 -30
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +3 -0
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +70 -49
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17307 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +7 -5
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +211 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +62 -91
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +9 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +20 -8
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -381
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -0,0 +1,2175 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { makeAssetRef, parseAssetRef } from "../core/asset-ref";
|
|
7
|
+
import { daysToMs, isAssetType } from "../core/common";
|
|
8
|
+
import { getDefaultLlmConfig, loadConfig } from "../core/config";
|
|
9
|
+
import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../core/errors";
|
|
10
|
+
import { appendEvent, readEvents } from "../core/events";
|
|
11
|
+
import { probeLock, releaseLock, tryAcquireLockSync } from "../core/file-lock";
|
|
12
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
13
|
+
import { detectAndWriteContradictions } from "../core/memory-contradiction-detect";
|
|
14
|
+
import { analyzeMemoryCleanup, applyMemoryCleanup, } from "../core/memory-improve";
|
|
15
|
+
import { getDbPath } from "../core/paths";
|
|
16
|
+
import { createProposal, expireStaleProposals, isProposalSkipped, listProposals, promoteProposal, purgeOrphanProposals, } from "../core/proposals";
|
|
17
|
+
import { openStateDatabase, purgeOldEvents, purgeOldImproveRuns } from "../core/state-db";
|
|
18
|
+
import { info, warn } from "../core/warn";
|
|
19
|
+
import { closeDatabase, getAllEntries, getEntryCount, getRetrievalCounts, getUtilityScoresByIds, getZeroResultSearches, openDatabase, openExistingDatabase, } from "../indexer/db";
|
|
20
|
+
import { ensureIndex } from "../indexer/ensure-index";
|
|
21
|
+
import { runGraphExtractionPass } from "../indexer/graph-extraction";
|
|
22
|
+
import { akmIndex } from "../indexer/indexer";
|
|
23
|
+
import { runMemoryInferencePass, } from "../indexer/memory-inference";
|
|
24
|
+
import { resolveAssetPath } from "../indexer/path-resolver";
|
|
25
|
+
import { getWritableStashDirs, resolveSourceEntries } from "../indexer/search-source";
|
|
26
|
+
import { runStalenessDetectionPass } from "../indexer/staleness-detect";
|
|
27
|
+
import { resolveImproveProcessRunnerFromProfile } from "../integrations/agent/runner";
|
|
28
|
+
import { getExecutionLogCandidates } from "../integrations/session-logs";
|
|
29
|
+
import { isProcessEnabled } from "../llm/feature-gate";
|
|
30
|
+
import { akmConsolidate } from "./consolidate";
|
|
31
|
+
import { akmDistill, deriveLessonRef, isDistillRefusedInputType } from "./distill";
|
|
32
|
+
import { deriveKnowledgeRef } from "./distill-promotion-policy";
|
|
33
|
+
import { countEvalCases, writeEvalCase } from "./eval-cases";
|
|
34
|
+
import { resolveImproveProfile, shouldSkipRef } from "./improve-profiles";
|
|
35
|
+
import { akmLint } from "./lint/index";
|
|
36
|
+
import { akmReflect } from "./reflect";
|
|
37
|
+
import { runSchemaRepairPass } from "./schema-repair";
|
|
38
|
+
import { checkDeadUrls } from "./url-checker";
|
|
39
|
+
function resolveImproveScope(scope) {
|
|
40
|
+
const trimmed = scope?.trim();
|
|
41
|
+
if (!trimmed)
|
|
42
|
+
return { mode: "all" };
|
|
43
|
+
try {
|
|
44
|
+
parseAssetRef(trimmed);
|
|
45
|
+
return { mode: "ref", value: trimmed };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
if (!isAssetType(trimmed)) {
|
|
49
|
+
throw new UsageError(`Unknown asset type: "${trimmed}". Valid types: memory, knowledge, skill, lesson, workflow, agent, command, script, wiki, vault, task.\n` +
|
|
50
|
+
`If you passed --format to akm improve, that flag is not supported — use it with akm search or akm show instead.`, "INVALID_FLAG_VALUE");
|
|
51
|
+
}
|
|
52
|
+
return { mode: "type", value: trimmed };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function collectEligibleRefs(scope, stashDir) {
|
|
56
|
+
if (scope.mode === "ref" && scope.value) {
|
|
57
|
+
const parsed = parseAssetRef(scope.value);
|
|
58
|
+
const writableDirs = new Set(getWritableStashDirs(stashDir).map((dir) => path.resolve(dir)));
|
|
59
|
+
const filePath = await findAssetFilePath(scope.value, stashDir, writableDirs);
|
|
60
|
+
if (!filePath) {
|
|
61
|
+
return {
|
|
62
|
+
plannedRefs: [],
|
|
63
|
+
memorySummary: { eligible: 0, derived: 0 },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
plannedRefs: [{ ref: scope.value, reason: "scope-ref" }],
|
|
68
|
+
memorySummary: {
|
|
69
|
+
eligible: parsed.type === "memory" ? 1 : 0,
|
|
70
|
+
derived: parsed.type === "memory" && parsed.name.endsWith(".derived") ? 1 : 0,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
let sources;
|
|
75
|
+
try {
|
|
76
|
+
sources = resolveSourceEntries(stashDir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { plannedRefs: [], memorySummary: { eligible: 0, derived: 0 } };
|
|
80
|
+
}
|
|
81
|
+
if (sources.length === 0) {
|
|
82
|
+
return { plannedRefs: [], memorySummary: { eligible: 0, derived: 0 } };
|
|
83
|
+
}
|
|
84
|
+
// Only operate on writable sources — never mutate read-only registry caches
|
|
85
|
+
// or remote stashes that the user did not mark writable.
|
|
86
|
+
let writableDirs;
|
|
87
|
+
try {
|
|
88
|
+
writableDirs = getWritableStashDirs(stashDir);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
writableDirs = sources.slice(0, 1).map((s) => s.path); // fallback: primary only
|
|
92
|
+
}
|
|
93
|
+
const writableDirSet = new Set(writableDirs.map((d) => path.resolve(d)));
|
|
94
|
+
let db;
|
|
95
|
+
try {
|
|
96
|
+
db = openExistingDatabase();
|
|
97
|
+
const entries = getAllEntries(db, scope.mode === "type" ? scope.value : undefined).filter((indexed) => {
|
|
98
|
+
// First apply the existing stashDir-scope filter (no-op when stashDir is unset).
|
|
99
|
+
if (!isEntryInScope(indexed.stashDir, indexed.filePath, stashDir))
|
|
100
|
+
return false;
|
|
101
|
+
// Then restrict to writable sources only.
|
|
102
|
+
return isEntryInWritableSource(indexed.stashDir, indexed.filePath, writableDirSet);
|
|
103
|
+
});
|
|
104
|
+
const planned = new Map();
|
|
105
|
+
let memoryEligible = 0;
|
|
106
|
+
let memoryDerived = 0;
|
|
107
|
+
for (const indexed of entries) {
|
|
108
|
+
const ref = makeAssetRef(indexed.entry.type, indexed.entry.name);
|
|
109
|
+
const isDerived = indexed.entry.name.endsWith(".derived");
|
|
110
|
+
// `.derived` memories are LLM-inferred and intentionally skip reflect
|
|
111
|
+
// (see the synthetic `derived-memory-reflect-skipped` branch in the
|
|
112
|
+
// improve loop). Enqueueing them here just produced one synthetic skip
|
|
113
|
+
// per derived memory per hour with no real work — pure churn observed
|
|
114
|
+
// 2026-05-21: 11 derived refs re-planned every hour during idle periods.
|
|
115
|
+
// The cleanup phase (analyzeMemoryCleanup) inspects derived memories
|
|
116
|
+
// independently of `plannedRefs`, so dropping them here loses nothing.
|
|
117
|
+
if (!isDerived && !planned.has(ref)) {
|
|
118
|
+
planned.set(ref, {
|
|
119
|
+
ref,
|
|
120
|
+
reason: scope.mode === "type" ? "scope-type" : indexed.entry.type === "memory" ? "memory-cleanup" : "scope-type",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (indexed.entry.type === "memory") {
|
|
124
|
+
memoryEligible += 1;
|
|
125
|
+
if (isDerived)
|
|
126
|
+
memoryDerived += 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
plannedRefs: [...planned.values()],
|
|
131
|
+
memorySummary: { eligible: memoryEligible, derived: memoryDerived },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
// The bun-test isolation guard must never be downgraded to "empty plan".
|
|
136
|
+
rethrowIfTestIsolationError(error);
|
|
137
|
+
if (error instanceof NotFoundError || error instanceof Error) {
|
|
138
|
+
return { plannedRefs: [], memorySummary: { eligible: 0, derived: 0 } };
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (db)
|
|
144
|
+
closeDatabase(db);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function isEntryInScope(entryStashDir, filePath, stashDir) {
|
|
148
|
+
if (!stashDir)
|
|
149
|
+
return true;
|
|
150
|
+
const resolvedEntryStashDir = path.resolve(entryStashDir);
|
|
151
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
152
|
+
const resolvedScopeStashDir = path.resolve(stashDir);
|
|
153
|
+
return (resolvedEntryStashDir === resolvedScopeStashDir ||
|
|
154
|
+
resolvedEntryStashDir.startsWith(`${resolvedScopeStashDir}${path.sep}`) ||
|
|
155
|
+
resolvedFilePath.startsWith(`${resolvedScopeStashDir}${path.sep}`));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Return true when the indexed entry belongs to one of the writable source
|
|
159
|
+
* directories. Entries from read-only registry caches or remote stashes that
|
|
160
|
+
* the user has not marked writable must never enter the improve/distill loop.
|
|
161
|
+
*/
|
|
162
|
+
function isEntryInWritableSource(entryStashDir, filePath, writableDirSet) {
|
|
163
|
+
const resolvedEntryStashDir = path.resolve(entryStashDir);
|
|
164
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
165
|
+
for (const writableDir of writableDirSet) {
|
|
166
|
+
if (resolvedEntryStashDir === writableDir ||
|
|
167
|
+
resolvedEntryStashDir.startsWith(`${writableDir}${path.sep}`) ||
|
|
168
|
+
resolvedFilePath.startsWith(`${writableDir}${path.sep}`)) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
function memoryCleanupParentRef(scope, stashDir) {
|
|
175
|
+
if (scope.mode !== "ref" || !scope.value)
|
|
176
|
+
return undefined;
|
|
177
|
+
const parsed = parseAssetRef(scope.value);
|
|
178
|
+
if (parsed.type !== "memory")
|
|
179
|
+
return undefined;
|
|
180
|
+
if (!parsed.name.endsWith(".derived"))
|
|
181
|
+
return scope.value;
|
|
182
|
+
const sources = resolveSourceEntries(stashDir);
|
|
183
|
+
for (const source of sources) {
|
|
184
|
+
const candidate = path.join(source.path, "memories", `${parsed.name}.md`);
|
|
185
|
+
if (!fs.existsSync(candidate))
|
|
186
|
+
continue;
|
|
187
|
+
const raw = fs.readFileSync(candidate, "utf8");
|
|
188
|
+
const fm = parseFrontmatter(raw).data;
|
|
189
|
+
const sourceRef = typeof fm.source === "string" ? fm.source : undefined;
|
|
190
|
+
if (sourceRef) {
|
|
191
|
+
try {
|
|
192
|
+
const parent = parseAssetRef(sourceRef.trim());
|
|
193
|
+
if (parent.type === "memory")
|
|
194
|
+
return makeAssetRef(parent.type, parent.name);
|
|
195
|
+
}
|
|
196
|
+
catch { }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return makeAssetRef("memory", parsed.name.slice(0, -".derived".length));
|
|
200
|
+
}
|
|
201
|
+
function isLessonCandidate(ref) {
|
|
202
|
+
// Only lesson assets need lesson-schema validation (description + when_to_use).
|
|
203
|
+
// Memories have their own distill path via shouldDistillMemoryRef.
|
|
204
|
+
// All other types go through reflect, not distill.
|
|
205
|
+
return parseAssetRef(ref).type === "lesson";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Planner-side check: should this ref enter the distill queue?
|
|
209
|
+
*
|
|
210
|
+
* Distill produces lessons from non-lesson sources. Two cases are eligible:
|
|
211
|
+
*
|
|
212
|
+
* 1. Memory refs that pass {@link shouldDistillMemoryRef} (the existing
|
|
213
|
+
* memory→lesson/knowledge promotion path).
|
|
214
|
+
*
|
|
215
|
+
* Refs whose `type` is in {@link DISTILL_REFUSED_INPUT_TYPES} (currently
|
|
216
|
+
* `lesson:*`) are explicitly excluded — distill refuses them at runtime and
|
|
217
|
+
* queuing them just produces a no-op `skipped` outcome per ref per hour. That
|
|
218
|
+
* planner waste was the bug fixed in commit
|
|
219
|
+
* fix(improve): drop distill-refused types from planner.
|
|
220
|
+
*
|
|
221
|
+
* Note: prior to this fix the gate used `isLessonCandidate(ref)` directly,
|
|
222
|
+
* which was true *only* for `lesson:*` refs — exactly the set distill refuses.
|
|
223
|
+
* The result: every hourly run re-queued the same lesson refs, the same skip
|
|
224
|
+
* message returned, and no work was ever done. See
|
|
225
|
+
* `tests/commands/improve-distill-planner-skip-lessons.test.ts`.
|
|
226
|
+
*/
|
|
227
|
+
function isDistillCandidateRef(ref, stashDir) {
|
|
228
|
+
const parsed = parseAssetRef(ref);
|
|
229
|
+
if (isDistillRefusedInputType(parsed.type))
|
|
230
|
+
return false;
|
|
231
|
+
return shouldDistillMemoryRef(ref, stashDir);
|
|
232
|
+
}
|
|
233
|
+
function shouldDistillMemoryRef(ref, stashDir) {
|
|
234
|
+
const parsed = parseAssetRef(ref);
|
|
235
|
+
if (parsed.type !== "memory")
|
|
236
|
+
return false;
|
|
237
|
+
const sources = resolveSourceEntries(stashDir);
|
|
238
|
+
for (const source of sources) {
|
|
239
|
+
const candidate = `${source.path}/memories/${parsed.name}.md`;
|
|
240
|
+
if (!fs.existsSync(candidate))
|
|
241
|
+
continue;
|
|
242
|
+
const raw = fs.readFileSync(candidate, "utf8");
|
|
243
|
+
const fm = parseFrontmatter(raw).data;
|
|
244
|
+
const quality = typeof fm.quality === "string" ? fm.quality : undefined;
|
|
245
|
+
if (quality === "proposed")
|
|
246
|
+
return false;
|
|
247
|
+
return !parsed.name.endsWith(".derived");
|
|
248
|
+
}
|
|
249
|
+
return !parsed.name.endsWith(".derived");
|
|
250
|
+
}
|
|
251
|
+
export async function akmImprove(options = {}) {
|
|
252
|
+
const scope = resolveImproveScope(options.scope);
|
|
253
|
+
const reflectFn = options.reflectFn ?? akmReflect;
|
|
254
|
+
const distillFn = options.distillFn ?? akmDistill;
|
|
255
|
+
const ensureIndexFn = options.ensureIndexFn ?? ensureIndex;
|
|
256
|
+
const reindexFn = options.reindexFn ?? akmIndex;
|
|
257
|
+
// Resolve the improve profile for this run. Profile drives type filtering,
|
|
258
|
+
// process gating, and default autoAccept/limit values.
|
|
259
|
+
const _earlyConfig = options.config ?? loadConfig();
|
|
260
|
+
const improveProfile = resolveImproveProfile(options.profile, _earlyConfig);
|
|
261
|
+
// Apply profile defaults — CLI flags take precedence over profile defaults.
|
|
262
|
+
// Rebuild options with effective values so all downstream stage functions
|
|
263
|
+
// automatically pick up the profile-driven defaults.
|
|
264
|
+
options = {
|
|
265
|
+
...options,
|
|
266
|
+
autoAccept: options.autoAccept ?? improveProfile.autoAccept,
|
|
267
|
+
limit: options.limit ?? improveProfile.limit,
|
|
268
|
+
};
|
|
269
|
+
let primaryStashDir;
|
|
270
|
+
try {
|
|
271
|
+
primaryStashDir = resolveSourceEntries(options.stashDir)[0]?.path;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
primaryStashDir = undefined;
|
|
275
|
+
}
|
|
276
|
+
// #339 fix: ensureIndex MUST run BEFORE collectEligibleRefs. The eligible-ref
|
|
277
|
+
// query reads the `entries` table; if a DB version upgrade just dropped that
|
|
278
|
+
// table (or the index is otherwise empty), the prior run order silently
|
|
279
|
+
// returned plannedRefs=[] and the improve loop no-op'd. Hoisting the call
|
|
280
|
+
// here repopulates the index first so the subsequent query sees fresh data.
|
|
281
|
+
const preEnsureCleanupWarnings = [];
|
|
282
|
+
if (primaryStashDir) {
|
|
283
|
+
// Probe pre-ensureIndex entry count to drive the loud-fail warning below.
|
|
284
|
+
// Best-effort: a missing DB / unreadable schema is the fresh-install case
|
|
285
|
+
// and not a bug — we silently skip the probe.
|
|
286
|
+
let preEnsureEntryCount;
|
|
287
|
+
try {
|
|
288
|
+
const dbPath = getDbPath();
|
|
289
|
+
if (fs.existsSync(dbPath)) {
|
|
290
|
+
const probeDb = openExistingDatabase();
|
|
291
|
+
try {
|
|
292
|
+
preEnsureEntryCount = getEntryCount(probeDb);
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
closeDatabase(probeDb);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
rethrowIfTestIsolationError(err);
|
|
301
|
+
// best-effort; leave preEnsureEntryCount undefined
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await ensureIndexFn(primaryStashDir);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
preEnsureCleanupWarnings.push(`ensureIndex failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
308
|
+
}
|
|
309
|
+
// #339 loud-fail: if the index was empty pre-ensureIndex but is now
|
|
310
|
+
// populated, a version-upgrade-triggered rebuild just happened. Surface
|
|
311
|
+
// that on stderr so the improve run is not silently masked by stale
|
|
312
|
+
// index state. Zero-before AND zero-after is the empty-stash case and
|
|
313
|
+
// is intentionally not warned (not a bug).
|
|
314
|
+
if (preEnsureEntryCount === 0) {
|
|
315
|
+
try {
|
|
316
|
+
const probeDb = openExistingDatabase();
|
|
317
|
+
let postCount = 0;
|
|
318
|
+
try {
|
|
319
|
+
postCount = getEntryCount(probeDb);
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
closeDatabase(probeDb);
|
|
323
|
+
}
|
|
324
|
+
if (postCount > 0) {
|
|
325
|
+
warn("[improve] index was empty after DB version upgrade — repopulating before continuing");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
rethrowIfTestIsolationError(err);
|
|
330
|
+
// best-effort
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const { plannedRefs, memorySummary } = await collectEligibleRefs(scope, options.stashDir);
|
|
335
|
+
const cleanupParentRef = memoryCleanupParentRef(scope, options.stashDir);
|
|
336
|
+
// M-1 (#367): Run contradiction-detection BEFORE analyzeMemoryCleanup so
|
|
337
|
+
// the SCC resolver in resolveFamilyContradictions has edges to work on.
|
|
338
|
+
// Best-effort: failures are warnings, never fatal.
|
|
339
|
+
if (primaryStashDir && shouldAnalyzeMemoryCleanup(scope, memorySummary.eligible, primaryStashDir)) {
|
|
340
|
+
try {
|
|
341
|
+
const config = options.config ?? loadConfig();
|
|
342
|
+
await detectAndWriteContradictions(primaryStashDir, config);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
// Non-fatal: contradiction detection is a best-effort pass.
|
|
346
|
+
warn(`[improve] contradiction detection failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const memoryCleanupPlan = shouldAnalyzeMemoryCleanup(scope, memorySummary.eligible, primaryStashDir)
|
|
350
|
+
? analyzeMemoryCleanup(primaryStashDir, cleanupParentRef ? { parentRef: cleanupParentRef } : undefined)
|
|
351
|
+
: undefined;
|
|
352
|
+
const guidance = memorySummary.eligible > 0
|
|
353
|
+
? "Improve folds memory cleanup into the same proposal queue: speculative promotions still go through reflect/distill proposals, while high-confidence redundant derived memories are moved into a recoverable cleanup archive instead of being left active in the stash."
|
|
354
|
+
: undefined;
|
|
355
|
+
if (options.dryRun) {
|
|
356
|
+
const result = {
|
|
357
|
+
schemaVersion: 1,
|
|
358
|
+
ok: true,
|
|
359
|
+
scope,
|
|
360
|
+
dryRun: true,
|
|
361
|
+
...(guidance ? { guidance } : {}),
|
|
362
|
+
memorySummary,
|
|
363
|
+
...(memoryCleanupPlan ? { memoryCleanup: shapeMemoryCleanup(memoryCleanupPlan) } : {}),
|
|
364
|
+
plannedRefs,
|
|
365
|
+
};
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
const resolvedLockPath = primaryStashDir
|
|
369
|
+
? path.join(primaryStashDir, ".akm", "improve.lock")
|
|
370
|
+
: path.join(options.stashDir ?? ".", ".akm", "improve.lock");
|
|
371
|
+
const MAX_LOCK_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
372
|
+
fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
|
|
373
|
+
const lockPayload = () => JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
374
|
+
const acquireLock = () => {
|
|
375
|
+
if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
|
|
376
|
+
return;
|
|
377
|
+
// Lock file already exists — probe to determine whether it's still held
|
|
378
|
+
// or whether the prior run died without cleaning up.
|
|
379
|
+
const probe = probeLock(resolvedLockPath, { staleAfterMs: MAX_LOCK_AGE_MS });
|
|
380
|
+
const rawContent = probe.state === "absent" ? undefined : probe.rawContent;
|
|
381
|
+
const lock = rawContent
|
|
382
|
+
? (() => {
|
|
383
|
+
try {
|
|
384
|
+
return JSON.parse(rawContent);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
})()
|
|
390
|
+
: null;
|
|
391
|
+
if (probe.state === "stale") {
|
|
392
|
+
// O-7 / #394: Emit improve_lock_recovered event before recovery so the
|
|
393
|
+
// audit trail records the abnormal prior-run exit (Temporal/Airflow pattern).
|
|
394
|
+
try {
|
|
395
|
+
appendEvent({
|
|
396
|
+
eventType: "improve_lock_recovered",
|
|
397
|
+
metadata: {
|
|
398
|
+
stalePid: lock?.pid ?? null,
|
|
399
|
+
lockedAt: lock?.startedAt ?? null,
|
|
400
|
+
recoveredAt: new Date().toISOString(),
|
|
401
|
+
lockAgeMs: probe.ageMs ?? null,
|
|
402
|
+
reason: probe.reason === "pid_dead" ? "pid_not_alive" : probe.reason,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* event emission is best-effort; never block lock recovery */
|
|
408
|
+
}
|
|
409
|
+
releaseLock(resolvedLockPath);
|
|
410
|
+
if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
|
|
411
|
+
return;
|
|
412
|
+
throw new ConfigError(`akm improve is already running. Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
|
|
413
|
+
}
|
|
414
|
+
throw new ConfigError(`akm improve is already running (PID ${lock?.pid}, started ${lock?.startedAt}). Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
|
|
415
|
+
};
|
|
416
|
+
acquireLock();
|
|
417
|
+
const budgetMs = options.timeoutMs ?? 2 * 60 * 60 * 1000; // default 2 hours
|
|
418
|
+
const startMs = Date.now();
|
|
419
|
+
// O-1 (#364): Create a shared AbortController derived from startMs + budgetMs.
|
|
420
|
+
// Every async seam receives this signal so a hung sub-call cannot extend the
|
|
421
|
+
// run past the declared budget.
|
|
422
|
+
// References: Anthropic *Building Effective Agents* (2024); CoALA §5 (arXiv:2309.02427).
|
|
423
|
+
const budgetAbortController = new AbortController();
|
|
424
|
+
const budgetTimer = setTimeout(() => budgetAbortController.abort("improve budget exhausted"), budgetMs);
|
|
425
|
+
// Clear the timer when the run ends to avoid keeping the event loop alive.
|
|
426
|
+
const clearBudgetTimer = () => clearTimeout(budgetTimer);
|
|
427
|
+
// I1: open a single state.db connection for the entire improve run so all
|
|
428
|
+
// appendEvent calls reuse one handle instead of open/migrate/close per call.
|
|
429
|
+
let eventsDb;
|
|
430
|
+
let eventsCtx;
|
|
431
|
+
try {
|
|
432
|
+
eventsDb = openStateDatabase();
|
|
433
|
+
eventsCtx = { db: eventsDb };
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
rethrowIfTestIsolationError(err);
|
|
437
|
+
// If we cannot open state.db up-front, fall back to per-call opens.
|
|
438
|
+
eventsCtx = {};
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const preparation = await runImprovePreparationStage({
|
|
442
|
+
scope,
|
|
443
|
+
options,
|
|
444
|
+
plannedRefs,
|
|
445
|
+
memoryCleanupPlan,
|
|
446
|
+
primaryStashDir,
|
|
447
|
+
memorySummary,
|
|
448
|
+
reindexFn,
|
|
449
|
+
startMs,
|
|
450
|
+
budgetMs,
|
|
451
|
+
eventsCtx,
|
|
452
|
+
initialCleanupWarnings: preEnsureCleanupWarnings,
|
|
453
|
+
improveProfile,
|
|
454
|
+
});
|
|
455
|
+
// D6: pre-load all proposal_rejected events from the last 30 days once,
|
|
456
|
+
// so the per-asset loop can use a Map lookup instead of N DB round trips.
|
|
457
|
+
const REJECTED_PROPOSAL_WINDOW_MS = daysToMs(30);
|
|
458
|
+
const rejectedProposalSince = new Date(Date.now() - REJECTED_PROPOSAL_WINDOW_MS).toISOString();
|
|
459
|
+
const allRejectedProposalEvents = readEvents({ type: "proposal_rejected", since: rejectedProposalSince }).events;
|
|
460
|
+
const rejectedProposalsByRef = new Map();
|
|
461
|
+
for (const e of allRejectedProposalEvents) {
|
|
462
|
+
if (e.ref && (!rejectedProposalsByRef.has(e.ref) || e.ts > (rejectedProposalsByRef.get(e.ref)?.ts ?? ""))) {
|
|
463
|
+
rejectedProposalsByRef.set(e.ref, e);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const { reflectsWithErrorContext, memoryRefsForInference } = await runImproveLoopStage({
|
|
467
|
+
scope,
|
|
468
|
+
options,
|
|
469
|
+
primaryStashDir,
|
|
470
|
+
reflectFn,
|
|
471
|
+
distillFn,
|
|
472
|
+
loopRefs: preparation.loopRefs,
|
|
473
|
+
actions: preparation.actions,
|
|
474
|
+
signalBearingSet: preparation.signalBearingSet,
|
|
475
|
+
distillCooledRefs: preparation.distillCooledRefs,
|
|
476
|
+
distillOnlyRefs: preparation.distillOnlyRefs,
|
|
477
|
+
recentErrors: preparation.recentErrors,
|
|
478
|
+
rejectedProposalsByRef,
|
|
479
|
+
utilityMap: preparation.utilityMap,
|
|
480
|
+
startMs,
|
|
481
|
+
budgetMs,
|
|
482
|
+
eventsCtx,
|
|
483
|
+
improveProfile,
|
|
484
|
+
});
|
|
485
|
+
const { allWarnings, consolidation, deadUrls, memoryInference, graphExtraction, stalenessDetection, maintenanceActions, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, } = await runImprovePostLoopStage({
|
|
486
|
+
scope,
|
|
487
|
+
options,
|
|
488
|
+
primaryStashDir,
|
|
489
|
+
actionableRefs: preparation.actionableRefs,
|
|
490
|
+
appliedCleanup: preparation.appliedCleanup,
|
|
491
|
+
cleanupWarnings: preparation.cleanupWarnings,
|
|
492
|
+
memorySummary,
|
|
493
|
+
memoryRefsForInference,
|
|
494
|
+
reindexFn,
|
|
495
|
+
eventsCtx,
|
|
496
|
+
// O-1 (#364): propagate wall-clock budget signal to post-loop maintenance.
|
|
497
|
+
budgetSignal: budgetAbortController.signal,
|
|
498
|
+
improveProfile,
|
|
499
|
+
});
|
|
500
|
+
const finalActions = maintenanceActions && maintenanceActions.length > 0
|
|
501
|
+
? [...preparation.actions, ...maintenanceActions]
|
|
502
|
+
: preparation.actions;
|
|
503
|
+
const result = {
|
|
504
|
+
schemaVersion: 1,
|
|
505
|
+
ok: true,
|
|
506
|
+
scope,
|
|
507
|
+
dryRun: false,
|
|
508
|
+
...(guidance ? { guidance } : {}),
|
|
509
|
+
memorySummary,
|
|
510
|
+
...(memoryCleanupPlan
|
|
511
|
+
? {
|
|
512
|
+
memoryCleanup: {
|
|
513
|
+
...shapeMemoryCleanup(memoryCleanupPlan),
|
|
514
|
+
...(preparation.appliedCleanup
|
|
515
|
+
? {
|
|
516
|
+
archived: preparation.appliedCleanup.archived,
|
|
517
|
+
...(preparation.appliedCleanup.transitionLogPath
|
|
518
|
+
? { transitionLogPath: preparation.appliedCleanup.transitionLogPath }
|
|
519
|
+
: {}),
|
|
520
|
+
...(preparation.appliedCleanup.transitionLogEntries !== undefined
|
|
521
|
+
? { transitionLogEntries: preparation.appliedCleanup.transitionLogEntries }
|
|
522
|
+
: {}),
|
|
523
|
+
...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
|
|
524
|
+
}
|
|
525
|
+
: preparation.cleanupWarnings.length > 0
|
|
526
|
+
? { warnings: preparation.cleanupWarnings }
|
|
527
|
+
: {}),
|
|
528
|
+
},
|
|
529
|
+
}
|
|
530
|
+
: {}),
|
|
531
|
+
plannedRefs: preparation.actionableRefs,
|
|
532
|
+
actions: finalActions,
|
|
533
|
+
...(preparation.validationFailures.length > 0 ? { validationFailures: preparation.validationFailures } : {}),
|
|
534
|
+
...(preparation.schemaRepairs.length > 0 ? { schemaRepairs: preparation.schemaRepairs } : {}),
|
|
535
|
+
...(consolidation.processed > 0 || consolidation.warnings.length > 0 ? { consolidation } : {}),
|
|
536
|
+
...(preparation.lintSummary !== undefined ? { lintSummary: preparation.lintSummary } : {}),
|
|
537
|
+
...(preparation.memoryIndexHealth !== undefined ? { memoryIndexHealth: preparation.memoryIndexHealth } : {}),
|
|
538
|
+
...(preparation.coverageGaps.length > 0 ? { coverageGaps: preparation.coverageGaps } : {}),
|
|
539
|
+
...(preparation.executionLogCandidates.length > 0
|
|
540
|
+
? { executionLogCandidates: preparation.executionLogCandidates }
|
|
541
|
+
: {}),
|
|
542
|
+
...(primaryStashDir !== undefined ? { evalCasesWritten: countEvalCases(primaryStashDir) } : {}),
|
|
543
|
+
...(deadUrls !== undefined && deadUrls.length > 0 ? { deadUrls } : {}),
|
|
544
|
+
...(reflectsWithErrorContext > 0 ? { reflectsWithErrorContext } : {}),
|
|
545
|
+
...(memoryInference ? { memoryInference } : {}),
|
|
546
|
+
...(graphExtraction ? { graphExtraction } : {}),
|
|
547
|
+
...(stalenessDetection ? { stalenessDetection } : {}),
|
|
548
|
+
...(orphansPurged !== undefined ? { orphansPurged } : {}),
|
|
549
|
+
...(proposalsExpired !== undefined && proposalsExpired > 0 ? { proposalsExpired } : {}),
|
|
550
|
+
reflectCooldownActions: finalActions.filter((a) => a.mode === "reflect-cooldown").length,
|
|
551
|
+
reflectSkippedActions: finalActions.filter((a) => a.mode === "reflect-skipped").length,
|
|
552
|
+
};
|
|
553
|
+
if (!result.dryRun)
|
|
554
|
+
emitImproveCompletedEvent(result, {
|
|
555
|
+
memoryInferenceDurationMs,
|
|
556
|
+
graphExtractionDurationMs,
|
|
557
|
+
totalDurationMs: Date.now() - startMs,
|
|
558
|
+
warningCount: allWarnings.length,
|
|
559
|
+
orphansPurged: orphansPurged ?? 0,
|
|
560
|
+
}, eventsCtx);
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
// D3: emit improve_failed on unexpected crash so dashboards can detect failures.
|
|
565
|
+
appendEvent({
|
|
566
|
+
eventType: "improve_failed",
|
|
567
|
+
ref: scope.mode === "ref" ? scope.value : `improve:${scope.mode}:${scope.value ?? "all"}`,
|
|
568
|
+
metadata: {
|
|
569
|
+
error: err instanceof Error ? err.message : String(err),
|
|
570
|
+
durationMs: Date.now() - startMs,
|
|
571
|
+
},
|
|
572
|
+
}, eventsCtx);
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
// O-1 (#364): Clear the budget abort timer so it does not keep the event
|
|
577
|
+
// loop alive after the run completes.
|
|
578
|
+
clearBudgetTimer();
|
|
579
|
+
try {
|
|
580
|
+
fs.unlinkSync(resolvedLockPath);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// ignore
|
|
584
|
+
}
|
|
585
|
+
// I1: close the long-lived state.db connection opened at the top of the run.
|
|
586
|
+
try {
|
|
587
|
+
eventsDb?.close();
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// ignore — DB may already be closed
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function emitImproveCompletedEvent(result, durations, eventsCtx) {
|
|
595
|
+
const actionCounts = {
|
|
596
|
+
reflect: 0,
|
|
597
|
+
reflectFailed: 0,
|
|
598
|
+
reflectCooldown: 0,
|
|
599
|
+
reflectSkipped: 0,
|
|
600
|
+
distill: 0,
|
|
601
|
+
distillSkipped: 0,
|
|
602
|
+
memoryPrune: 0,
|
|
603
|
+
memoryInference: 0,
|
|
604
|
+
graphExtraction: 0,
|
|
605
|
+
error: 0,
|
|
606
|
+
};
|
|
607
|
+
for (const action of result.actions ?? []) {
|
|
608
|
+
switch (action.mode) {
|
|
609
|
+
case "reflect":
|
|
610
|
+
actionCounts.reflect += 1;
|
|
611
|
+
break;
|
|
612
|
+
case "reflect-failed":
|
|
613
|
+
actionCounts.reflectFailed += 1;
|
|
614
|
+
break;
|
|
615
|
+
case "reflect-cooldown":
|
|
616
|
+
actionCounts.reflectCooldown += 1;
|
|
617
|
+
break;
|
|
618
|
+
case "reflect-skipped":
|
|
619
|
+
actionCounts.reflectSkipped += 1;
|
|
620
|
+
break;
|
|
621
|
+
case "distill":
|
|
622
|
+
actionCounts.distill += 1;
|
|
623
|
+
break;
|
|
624
|
+
case "distill-skipped":
|
|
625
|
+
actionCounts.distillSkipped += 1;
|
|
626
|
+
break;
|
|
627
|
+
case "memory-prune":
|
|
628
|
+
actionCounts.memoryPrune += 1;
|
|
629
|
+
break;
|
|
630
|
+
case "memory-inference":
|
|
631
|
+
actionCounts.memoryInference += 1;
|
|
632
|
+
break;
|
|
633
|
+
case "graph-extraction":
|
|
634
|
+
actionCounts.graphExtraction += 1;
|
|
635
|
+
break;
|
|
636
|
+
case "error":
|
|
637
|
+
actionCounts.error += 1;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
appendEvent({
|
|
642
|
+
eventType: "improve_completed",
|
|
643
|
+
ref: result.scope.mode === "ref"
|
|
644
|
+
? result.scope.value
|
|
645
|
+
: `improve:${result.scope.mode}:${result.scope.value ?? "all"}`,
|
|
646
|
+
metadata: {
|
|
647
|
+
plannedRefs: result.plannedRefs.length,
|
|
648
|
+
reflectActions: actionCounts.reflect,
|
|
649
|
+
distillActions: actionCounts.distill,
|
|
650
|
+
distillSkippedActions: actionCounts.distillSkipped,
|
|
651
|
+
memoryPruneActions: actionCounts.memoryPrune,
|
|
652
|
+
memoryInferenceActions: actionCounts.memoryInference,
|
|
653
|
+
graphExtractionActions: actionCounts.graphExtraction,
|
|
654
|
+
errorActions: actionCounts.error,
|
|
655
|
+
reflectFailedActions: actionCounts.reflectFailed,
|
|
656
|
+
reflectCooldownActions: actionCounts.reflectCooldown,
|
|
657
|
+
reflectSkippedActions: actionCounts.reflectSkipped,
|
|
658
|
+
reflectsWithErrorContext: result.reflectsWithErrorContext ?? 0,
|
|
659
|
+
coverageGapCount: result.coverageGaps?.length ?? 0,
|
|
660
|
+
executionLogCandidateCount: result.executionLogCandidates?.length ?? 0,
|
|
661
|
+
evalCasesWritten: result.evalCasesWritten ?? 0,
|
|
662
|
+
deadUrlCount: result.deadUrls?.length ?? 0,
|
|
663
|
+
memoryEligible: result.memorySummary.eligible,
|
|
664
|
+
memoryDerived: result.memorySummary.derived,
|
|
665
|
+
memoryCleanupPruneCandidates: result.memoryCleanup?.pruneCandidates.length ?? 0,
|
|
666
|
+
memoryCleanupContradictionCandidates: result.memoryCleanup?.contradictionCandidates.length ?? 0,
|
|
667
|
+
memoryCleanupBeliefStateTransitions: result.memoryCleanup?.beliefStateTransitions.length ?? 0,
|
|
668
|
+
memoryCleanupConsolidationCandidates: result.memoryCleanup?.consolidationCandidates.length ?? 0,
|
|
669
|
+
memoryCleanupArchived: result.memoryCleanup?.archived?.length ?? 0,
|
|
670
|
+
memoryCleanupWarnings: result.memoryCleanup?.warnings?.length ?? 0,
|
|
671
|
+
consolidationProcessed: result.consolidation?.processed ?? 0,
|
|
672
|
+
consolidationDurationMs: result.consolidation?.durationMs ?? 0,
|
|
673
|
+
memoryInferenceWrites: result.memoryInference?.writtenFacts ?? 0,
|
|
674
|
+
memoryInferenceDurationMs: durations.memoryInferenceDurationMs,
|
|
675
|
+
graphExtractionExtractedFiles: result.graphExtraction?.quality.extractedFiles ?? 0,
|
|
676
|
+
graphExtractionDurationMs: durations.graphExtractionDurationMs,
|
|
677
|
+
// New metrics for tuning the improve loop.
|
|
678
|
+
...(durations.totalDurationMs !== undefined ? { durationMs: durations.totalDurationMs } : {}),
|
|
679
|
+
...(durations.warningCount !== undefined ? { warningCount: durations.warningCount } : {}),
|
|
680
|
+
...(durations.orphansPurged !== undefined ? { orphansPurged: durations.orphansPurged } : {}),
|
|
681
|
+
...(result.graphExtraction?.quality
|
|
682
|
+
? {
|
|
683
|
+
graphCoverage: result.graphExtraction.quality.extractionCoverage,
|
|
684
|
+
graphDensity: result.graphExtraction.quality.density,
|
|
685
|
+
graphEntities: result.graphExtraction.quality.entityCount,
|
|
686
|
+
}
|
|
687
|
+
: {}),
|
|
688
|
+
},
|
|
689
|
+
}, eventsCtx);
|
|
690
|
+
}
|
|
691
|
+
async function runImprovePreparationStage(args) {
|
|
692
|
+
const { scope, options, plannedRefs, memoryCleanupPlan, primaryStashDir, reindexFn, startMs, budgetMs, eventsCtx, initialCleanupWarnings, improveProfile, } = args;
|
|
693
|
+
const actions = [];
|
|
694
|
+
const cleanupWarnings = initialCleanupWarnings ? [...initialCleanupWarnings] : [];
|
|
695
|
+
// Phase 0 — MEMORY.md budget check (200-line cap; warn at 180)
|
|
696
|
+
let memoryIndexHealth;
|
|
697
|
+
if (primaryStashDir) {
|
|
698
|
+
const memoryMdPath = path.join(primaryStashDir, "memories", "MEMORY.md");
|
|
699
|
+
if (fs.existsSync(memoryMdPath)) {
|
|
700
|
+
try {
|
|
701
|
+
const lines = fs.readFileSync(memoryMdPath, "utf8").split("\n").length;
|
|
702
|
+
const overBudget = lines >= 180;
|
|
703
|
+
memoryIndexHealth = { lineCount: lines, overBudget };
|
|
704
|
+
if (overBudget) {
|
|
705
|
+
cleanupWarnings.push(`MEMORY.md has ${lines} lines (budget: 200). Consolidation strongly recommended.`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// best-effort
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Phase 0 — execution log synthesis
|
|
714
|
+
let executionLogCandidates = [];
|
|
715
|
+
try {
|
|
716
|
+
const logEntries = getExecutionLogCandidates(7);
|
|
717
|
+
executionLogCandidates = logEntries.filter((e) => e.isFailurePattern).map((e) => e.topic);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// best-effort
|
|
721
|
+
}
|
|
722
|
+
// eligibleCount = raw pre-filter count (before cooldown/signal/cleanup filters).
|
|
723
|
+
// improve_completed.plannedRefs = post-filter count of refs that actually entered the loop.
|
|
724
|
+
appendEvent({
|
|
725
|
+
eventType: "improve_invoked",
|
|
726
|
+
ref: scope.mode === "ref" ? scope.value : `improve:${scope.mode}:${scope.value ?? "all"}`,
|
|
727
|
+
metadata: { scope, dryRun: options.dryRun ?? false, eligibleCount: plannedRefs.length },
|
|
728
|
+
}, eventsCtx);
|
|
729
|
+
// ensureIndex now runs in akmImprove() BEFORE collectEligibleRefs so the
|
|
730
|
+
// eligible-ref query sees a populated `entries` table on the very first
|
|
731
|
+
// pass after a DB version upgrade (#339). Any failure messages from that
|
|
732
|
+
// earlier call were threaded in via args.initialCleanupWarnings.
|
|
733
|
+
let appliedCleanup;
|
|
734
|
+
try {
|
|
735
|
+
appliedCleanup =
|
|
736
|
+
primaryStashDir && memoryCleanupPlan ? applyMemoryCleanup(primaryStashDir, memoryCleanupPlan) : undefined;
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
cleanupWarnings.push(`applyMemoryCleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
740
|
+
}
|
|
741
|
+
const archivedRefs = appliedCleanup?.archived.map((record) => record.ref) ?? [];
|
|
742
|
+
const removed = new Set(archivedRefs);
|
|
743
|
+
const postCleanupRefs = archivedRefs.length === 0 ? plannedRefs : plannedRefs.filter((r) => !removed.has(r.ref));
|
|
744
|
+
// ── Phase 1: validation pass + schema repair (run on full postCleanupRefs) ──
|
|
745
|
+
// Identifies refs whose on-disk asset has structural problems. Validation
|
|
746
|
+
// failures are excluded from every downstream bucket. Run early so the
|
|
747
|
+
// cooldown partition operates on a clean set.
|
|
748
|
+
if (appliedCleanup) {
|
|
749
|
+
for (const candidate of memoryCleanupPlan?.pruneCandidates ?? []) {
|
|
750
|
+
const archived = appliedCleanup.archived.find((record) => record.ref === candidate.ref);
|
|
751
|
+
if (!archived)
|
|
752
|
+
continue;
|
|
753
|
+
actions.push({
|
|
754
|
+
ref: candidate.ref,
|
|
755
|
+
mode: "memory-prune",
|
|
756
|
+
result: { ok: true, pruned: true, reason: candidate.reason },
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
if ((appliedCleanup.archived.length > 0 || appliedCleanup.beliefStateTransitions.length > 0) && primaryStashDir) {
|
|
760
|
+
try {
|
|
761
|
+
await reindexFn({ stashDir: primaryStashDir });
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
cleanupWarnings.push(`reindex after cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const validationFailures = [];
|
|
769
|
+
for (const candidate of postCleanupRefs) {
|
|
770
|
+
try {
|
|
771
|
+
const filePath = await findAssetFilePath(candidate.ref, options.stashDir);
|
|
772
|
+
if (!filePath) {
|
|
773
|
+
validationFailures.push({ ref: candidate.ref, reason: "file not found on disk" });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (path.extname(filePath).toLowerCase() !== ".md") {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (isLessonCandidate(candidate.ref)) {
|
|
780
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
781
|
+
const fm = parseFrontmatter(raw).data;
|
|
782
|
+
if (!fm.description)
|
|
783
|
+
validationFailures.push({ ref: candidate.ref, reason: "missing description" });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
catch (e) {
|
|
787
|
+
validationFailures.push({ ref: candidate.ref, reason: String(e) });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (validationFailures.length > 0) {
|
|
791
|
+
info(`[improve] ${validationFailures.length} assets have validation issues (will attempt schema repair):`);
|
|
792
|
+
for (const f of validationFailures)
|
|
793
|
+
info(` ${f.ref}: ${f.reason}`);
|
|
794
|
+
}
|
|
795
|
+
let schemaRepairs = [];
|
|
796
|
+
let repairedRefs = new Set();
|
|
797
|
+
// Schema repair pass: attempt to fix validation failures via LLM before skipping.
|
|
798
|
+
if (validationFailures.length > 0 && options.repairValidationFailures !== false) {
|
|
799
|
+
const baseConfigForRepair = options.config ?? loadConfig();
|
|
800
|
+
const llmCfg = getDefaultLlmConfig(baseConfigForRepair);
|
|
801
|
+
if (llmCfg) {
|
|
802
|
+
const result = await runSchemaRepairPass(validationFailures, {
|
|
803
|
+
startMs,
|
|
804
|
+
budgetMs,
|
|
805
|
+
llmConfig: llmCfg,
|
|
806
|
+
stashDir: options.stashDir,
|
|
807
|
+
findFilePath: findAssetFilePath,
|
|
808
|
+
isLessonCandidateFn: isLessonCandidate,
|
|
809
|
+
});
|
|
810
|
+
schemaRepairs = result.repairs;
|
|
811
|
+
repairedRefs = result.repairedRefs;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const validationFailureRefs = new Set(validationFailures.filter((f) => !repairedRefs.has(f.ref)).map((f) => f.ref));
|
|
815
|
+
if (repairedRefs.size > 0) {
|
|
816
|
+
info(`[improve] schema repair fixed ${repairedRefs.size}/${validationFailures.length} validation failures; ${validationFailureRefs.size} remain`);
|
|
817
|
+
}
|
|
818
|
+
// Phase 0.5 — structural hygiene pass
|
|
819
|
+
let lintSummary;
|
|
820
|
+
if (primaryStashDir) {
|
|
821
|
+
try {
|
|
822
|
+
const lintResult = akmLint({ fix: true, dir: primaryStashDir });
|
|
823
|
+
lintSummary = { fixed: lintResult.summary.fixed, flagged: lintResult.summary.flagged };
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
// lint is best-effort; never block improve
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// O-5 / #378: Per-originator rolling error windows.
|
|
830
|
+
// Reflexion (arXiv:2303.11366) warns that cross-task verbal critique
|
|
831
|
+
// contamination degrades below single-shot baseline. Each originator key
|
|
832
|
+
// ("schema-repair", "reflect") maintains its own rolling window so that
|
|
833
|
+
// schema-repair failures are not injected as avoidPatterns into reflect calls.
|
|
834
|
+
const recentErrors = {};
|
|
835
|
+
const RECENT_ERRORS_CAP = 3;
|
|
836
|
+
// Helper: push an error onto an originator's rolling window.
|
|
837
|
+
function pushRecentError(originator, msg) {
|
|
838
|
+
if (!recentErrors[originator])
|
|
839
|
+
recentErrors[originator] = [];
|
|
840
|
+
recentErrors[originator].push(msg);
|
|
841
|
+
if (recentErrors[originator].length > RECENT_ERRORS_CAP)
|
|
842
|
+
recentErrors[originator].shift();
|
|
843
|
+
}
|
|
844
|
+
// Seed schema-repair originator window from any schema-repair errors.
|
|
845
|
+
for (const repair of schemaRepairs) {
|
|
846
|
+
if (repair.outcome === "error") {
|
|
847
|
+
const errMsg = repair.error ?? `schema repair error: ${repair.reason}`;
|
|
848
|
+
pushRecentError("schema-repair", errMsg);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// ── Phase 2: cooldown sets built EARLY ────────────────────────────────────
|
|
852
|
+
// Read all cooldown-relevant events in 4 bulk queries and materialise two
|
|
853
|
+
// Sets used by the partition (next phase). Doing this before signal/feedback/
|
|
854
|
+
// utility/sort means the expensive ranking work only runs on refs that can
|
|
855
|
+
// actually be processed this run.
|
|
856
|
+
//
|
|
857
|
+
// SM-2 tier for reflect uses promoted/rejected events (recorded by
|
|
858
|
+
// `akm proposal accept/reject`) rather than the per-ref listProposals()
|
|
859
|
+
// filesystem scan, giving identical tier logic without touching the disk.
|
|
860
|
+
// Built-in per-type reflect cooldown defaults. Higher-churn types get shorter
|
|
861
|
+
// windows; stable reference material gets longer ones.
|
|
862
|
+
// Overridable via profiles.improve[name].processes.reflect.cooldownByType,
|
|
863
|
+
// or globally via --reflect-cooldown-days (CLI flag, highest priority).
|
|
864
|
+
const REFLECT_COOLDOWN_BUILTIN = {
|
|
865
|
+
memory: 2,
|
|
866
|
+
lesson: 7,
|
|
867
|
+
workflow: 30,
|
|
868
|
+
skill: 30,
|
|
869
|
+
agent: 30,
|
|
870
|
+
command: 30,
|
|
871
|
+
knowledge: 30,
|
|
872
|
+
script: 30,
|
|
873
|
+
wiki: 30,
|
|
874
|
+
task: 60,
|
|
875
|
+
};
|
|
876
|
+
const REFLECT_COOLDOWN_FALLBACK = 30;
|
|
877
|
+
const profileCooldownByType = Object.fromEntries(Object.entries(improveProfile.processes?.reflect?.cooldownByType ?? {}).filter((entry) => entry[1] !== undefined));
|
|
878
|
+
const REFLECT_COOLDOWN_BY_TYPE = {
|
|
879
|
+
...REFLECT_COOLDOWN_BUILTIN,
|
|
880
|
+
...profileCooldownByType,
|
|
881
|
+
};
|
|
882
|
+
const profileCooldownDays = improveProfile.processes?.reflect?.cooldownDays;
|
|
883
|
+
const reflectCooldownForRef = (ref) => {
|
|
884
|
+
if (options.reflectCooldownDays !== undefined)
|
|
885
|
+
return options.reflectCooldownDays;
|
|
886
|
+
const type = ref.split(":")[0] ?? "";
|
|
887
|
+
return REFLECT_COOLDOWN_BY_TYPE[type] ?? profileCooldownDays ?? REFLECT_COOLDOWN_FALLBACK;
|
|
888
|
+
};
|
|
889
|
+
const DISTILL_COOLDOWN_DAYS = options.distillCooldownDays ?? 1;
|
|
890
|
+
const reflectCooledRefs = new Set();
|
|
891
|
+
const distillCooledRefs = new Set();
|
|
892
|
+
// Use the largest possible reflect window when querying bulk events so we
|
|
893
|
+
// don't miss cooldown records for long-window types (e.g. lesson = 90 days).
|
|
894
|
+
const maxReflectCooldownDays = options.reflectCooldownDays !== undefined
|
|
895
|
+
? options.reflectCooldownDays
|
|
896
|
+
: Math.max(...Object.values(REFLECT_COOLDOWN_BY_TYPE));
|
|
897
|
+
const effectiveDistillCooldown = DISTILL_COOLDOWN_DAYS;
|
|
898
|
+
const reflectCooldownActive = options.reflectCooldownDays !== 0;
|
|
899
|
+
if (reflectCooldownActive || effectiveDistillCooldown > 0) {
|
|
900
|
+
const bulkWindowMs = daysToMs(Math.max(maxReflectCooldownDays, effectiveDistillCooldown));
|
|
901
|
+
const bulkSince = new Date(Date.now() - bulkWindowMs).toISOString();
|
|
902
|
+
// TODO(refactor): 4 separate readEvents calls with same time bound — could be one WHERE type IN (...) query if readEvents grew a `types?: string[]` option. Marginal win with WAL+long-lived connection, defer.
|
|
903
|
+
const bulkReflects = readEvents({ type: "reflect_invoked", since: bulkSince }).events;
|
|
904
|
+
const bulkDistills = readEvents({ type: "distill_invoked", since: bulkSince }).events;
|
|
905
|
+
const bulkPromoted = readEvents({ type: "promoted", since: bulkSince }).events;
|
|
906
|
+
const bulkRejected = readEvents({ type: "rejected", since: bulkSince }).events;
|
|
907
|
+
const promotedTs = new Map();
|
|
908
|
+
for (const e of bulkPromoted) {
|
|
909
|
+
if (e.ref && (e.ts ?? "") > (promotedTs.get(e.ref) ?? ""))
|
|
910
|
+
promotedTs.set(e.ref, e.ts ?? "");
|
|
911
|
+
}
|
|
912
|
+
const rejectedTs = new Map();
|
|
913
|
+
for (const e of bulkRejected) {
|
|
914
|
+
if (e.ref && (e.ts ?? "") > (rejectedTs.get(e.ref) ?? ""))
|
|
915
|
+
rejectedTs.set(e.ref, e.ts ?? "");
|
|
916
|
+
}
|
|
917
|
+
if (reflectCooldownActive) {
|
|
918
|
+
const latestReflect = new Map();
|
|
919
|
+
for (const e of bulkReflects) {
|
|
920
|
+
if (e.ref && (e.ts ?? "") > (latestReflect.get(e.ref) ?? ""))
|
|
921
|
+
latestReflect.set(e.ref, e.ts ?? "");
|
|
922
|
+
}
|
|
923
|
+
for (const [ref, lastTs] of latestReflect) {
|
|
924
|
+
if (!lastTs)
|
|
925
|
+
continue;
|
|
926
|
+
const hasAccepted = (promotedTs.get(ref) ?? "") > lastTs;
|
|
927
|
+
const hasRejected = (rejectedTs.get(ref) ?? "") > lastTs;
|
|
928
|
+
if (hasAccepted)
|
|
929
|
+
continue;
|
|
930
|
+
const typeCooldown = reflectCooldownForRef(ref);
|
|
931
|
+
const effectiveCooldownDays = hasRejected ? Math.min(typeCooldown, 3) : typeCooldown;
|
|
932
|
+
if (Date.now() - new Date(lastTs).getTime() < daysToMs(effectiveCooldownDays)) {
|
|
933
|
+
reflectCooledRefs.add(ref);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (DISTILL_COOLDOWN_DAYS > 0) {
|
|
938
|
+
const distillCooldownMs = daysToMs(DISTILL_COOLDOWN_DAYS);
|
|
939
|
+
// B5: track both queued and validation_failed outcomes for cooldown.
|
|
940
|
+
// validation_failed assets previously were never cooled so they re-distilled on every run.
|
|
941
|
+
const validationFailedCooldownMs = daysToMs(Math.ceil(DISTILL_COOLDOWN_DAYS / 2));
|
|
942
|
+
const latestQueuedDistill = new Map();
|
|
943
|
+
const latestValidationFailed = new Map();
|
|
944
|
+
for (const e of bulkDistills) {
|
|
945
|
+
if (e.ref && e.metadata?.outcome === "queued" && (e.ts ?? "") > (latestQueuedDistill.get(e.ref) ?? "")) {
|
|
946
|
+
latestQueuedDistill.set(e.ref, e.ts ?? "");
|
|
947
|
+
}
|
|
948
|
+
if (e.ref &&
|
|
949
|
+
e.metadata?.outcome === "validation_failed" &&
|
|
950
|
+
(e.ts ?? "") > (latestValidationFailed.get(e.ref) ?? "")) {
|
|
951
|
+
latestValidationFailed.set(e.ref, e.ts ?? "");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
for (const [ref, lastTs] of latestQueuedDistill) {
|
|
955
|
+
if (lastTs && Date.now() - new Date(lastTs).getTime() < distillCooldownMs) {
|
|
956
|
+
distillCooledRefs.add(ref);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
for (const [ref, lastTs] of latestValidationFailed) {
|
|
960
|
+
if (lastTs && Date.now() - new Date(lastTs).getTime() < validationFailedCooldownMs) {
|
|
961
|
+
distillCooledRefs.add(ref);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
// ── Phase 3: partition postCleanupRefs by cooldown BEFORE signal/sort ─────
|
|
967
|
+
// Three buckets (validation failures are excluded entirely):
|
|
968
|
+
// eligibleRefs — not reflect-cooled (normal reflect path; distill
|
|
969
|
+
// guard remains in the loop for distill-cooled refs).
|
|
970
|
+
// distillOnlyRefs — reflect-cooled, distill-cooldown expired, is a distill
|
|
971
|
+
// candidate → skip reflect but allow distill (Bug D2).
|
|
972
|
+
// fullySkippedCount — reflect-cooled AND (distill-cooled OR not a candidate)
|
|
973
|
+
// → emit synthetic skip action and exclude from further
|
|
974
|
+
// processing (signal/sort never sees these).
|
|
975
|
+
//
|
|
976
|
+
// Bug D1: distill-cooled distill-candidates that are NOT reflect-cooled have their
|
|
977
|
+
// synthetic distill-skipped action emitted here before any LLM call or budget check.
|
|
978
|
+
// Bug B1: synthetic skip emissions for the fully-skipped bucket happen here so
|
|
979
|
+
// telemetry (improve_skipped events with reason=reflect_cooldown/distill_cooldown)
|
|
980
|
+
// remains accurate even though these refs do not enter ranking.
|
|
981
|
+
const DISTILL_COOLDOWN_DAYS_PREFILT = options.distillCooldownDays ?? 1;
|
|
982
|
+
const eligibleRefs = [];
|
|
983
|
+
const distillOnlyRefs = [];
|
|
984
|
+
let fullySkippedCount = 0;
|
|
985
|
+
const preCooldownCount = postCleanupRefs.length;
|
|
986
|
+
// O-2 (#365): When the user explicitly targets a single ref via `--scope`,
|
|
987
|
+
// their intent is unambiguous — they want a fresh evaluation now. Cooldown
|
|
988
|
+
// policies are designed for unattended nightly runs; silently blocking an
|
|
989
|
+
// explicit retry violates the principle of least surprise (Sagas, 1987).
|
|
990
|
+
const scopeRefBypass = scope.mode === "ref";
|
|
991
|
+
for (const r of postCleanupRefs) {
|
|
992
|
+
if (validationFailureRefs.has(r.ref))
|
|
993
|
+
continue;
|
|
994
|
+
// When --scope <ref> is active, bypass all cooldown checks for this ref.
|
|
995
|
+
if (scopeRefBypass) {
|
|
996
|
+
eligibleRefs.push(r);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
const onReflectCooldown = reflectCooledRefs.has(r.ref);
|
|
1000
|
+
const onDistillCooldown = DISTILL_COOLDOWN_DAYS_PREFILT > 0 && distillCooledRefs.has(r.ref);
|
|
1001
|
+
// Pre-fix this read `isLessonCandidate(r.ref) || shouldDistillMemoryRef(...)`,
|
|
1002
|
+
// which queued `lesson:*` refs into the distill path even though distill
|
|
1003
|
+
// refuses them at runtime (DISTILL_REFUSED_INPUT_TYPES). That was pure
|
|
1004
|
+
// planner waste — observed re-queuing the same 19 lessons every hourly run.
|
|
1005
|
+
const isDistillCandidate = isDistillCandidateRef(r.ref, options.stashDir);
|
|
1006
|
+
if (!onReflectCooldown) {
|
|
1007
|
+
if (onDistillCooldown && isDistillCandidate) {
|
|
1008
|
+
// Bug D1: pre-emit synthetic distill-skipped action before any LLM call.
|
|
1009
|
+
actions.push({ ref: r.ref, mode: "distill-skipped", result: { ok: true, reason: "distill cooldown" } });
|
|
1010
|
+
// TODO(refactor): 7 inline appendEvent calls with eventType "improve_skipped". A helper emitImproveSkipped(ref, reason, extra?) would consolidate them, but each site has slightly different metadata shape — defer until shapes converge.
|
|
1011
|
+
appendEvent({
|
|
1012
|
+
eventType: "improve_skipped",
|
|
1013
|
+
ref: r.ref,
|
|
1014
|
+
metadata: { reason: "distill_cooldown", cooldownDays: DISTILL_COOLDOWN_DAYS_PREFILT },
|
|
1015
|
+
}, eventsCtx);
|
|
1016
|
+
}
|
|
1017
|
+
// Asset is not on reflect cooldown — allow reflect (distill-cooled path
|
|
1018
|
+
// already emitted its synthetic action above; the loop's distillCooledRefs
|
|
1019
|
+
// guard prevents the actual distill call).
|
|
1020
|
+
eligibleRefs.push(r);
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
if (!onDistillCooldown && isDistillCandidate) {
|
|
1024
|
+
// Bug D2: reflect-cooled but distill cooldown expired and is a distill candidate.
|
|
1025
|
+
distillOnlyRefs.push(r);
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
// Fully cooled or not a distill candidate — emit synthetic skip and exclude.
|
|
1029
|
+
fullySkippedCount++;
|
|
1030
|
+
actions.push({
|
|
1031
|
+
ref: r.ref,
|
|
1032
|
+
mode: "distill-skipped",
|
|
1033
|
+
result: { ok: true, reason: "reflect cooldown (pre-filtered)" },
|
|
1034
|
+
});
|
|
1035
|
+
appendEvent({ eventType: "improve_skipped", ref: r.ref, metadata: { reason: "reflect_cooldown" } }, eventsCtx);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
// ── Phase 4: signal/feedback/utility/sort on the reduced set ──────────────
|
|
1040
|
+
// Everything from here works only on (eligibleRefs ∪ distillOnlyRefs). The
|
|
1041
|
+
// fully-skipped bucket has already been routed and emitted; we deliberately
|
|
1042
|
+
// avoid spending DB/CPU on refs that cannot enter the loop.
|
|
1043
|
+
const processableRefs = [...eligibleRefs, ...distillOnlyRefs];
|
|
1044
|
+
// Gap 6: only surface feedback signals from the last 30 days so that
|
|
1045
|
+
// ancient one-off feedback events don't permanently lock an asset into
|
|
1046
|
+
// every improve run. Assets with only stale signals fall through to the
|
|
1047
|
+
// high-retrieval path (P0-A) or are skipped until new signals arrive.
|
|
1048
|
+
const FEEDBACK_SIGNAL_WINDOW_DAYS = 30;
|
|
1049
|
+
const feedbackSinceCutoff = new Date(Date.now() - daysToMs(FEEDBACK_SIGNAL_WINDOW_DAYS)).toISOString();
|
|
1050
|
+
// Pre-compute feedback summary per ref in a single pass so we don't issue
|
|
1051
|
+
// two readEvents({type:"feedback", ref}) per asset (one for signal filtering,
|
|
1052
|
+
// one for ratio computation).
|
|
1053
|
+
const feedbackSummary = new Map();
|
|
1054
|
+
for (const candidate of processableRefs) {
|
|
1055
|
+
const { events } = readEvents({ type: "feedback", ref: candidate.ref });
|
|
1056
|
+
let hasSignal = false;
|
|
1057
|
+
let positive = 0;
|
|
1058
|
+
let negative = 0;
|
|
1059
|
+
for (const e of events) {
|
|
1060
|
+
if (!hasSignal &&
|
|
1061
|
+
(e.ts ?? "") >= feedbackSinceCutoff &&
|
|
1062
|
+
e.metadata !== undefined &&
|
|
1063
|
+
(typeof e.metadata.signal === "string" || typeof e.metadata.note === "string")) {
|
|
1064
|
+
hasSignal = true;
|
|
1065
|
+
}
|
|
1066
|
+
if (e.metadata?.signal === "positive")
|
|
1067
|
+
positive++;
|
|
1068
|
+
else if (e.metadata?.signal === "negative")
|
|
1069
|
+
negative++;
|
|
1070
|
+
}
|
|
1071
|
+
feedbackSummary.set(candidate.ref, { hasSignal, positive, negative });
|
|
1072
|
+
}
|
|
1073
|
+
const signalFiltered = processableRefs.filter((candidate) => feedbackSummary.get(candidate.ref)?.hasSignal === true);
|
|
1074
|
+
// P0-A: also surface zero-feedback assets that have been retrieved many times.
|
|
1075
|
+
const RETRIEVAL_COUNT_THRESHOLD = options.minRetrievalCount ?? 5;
|
|
1076
|
+
const signalBearingSet = new Set(signalFiltered.map((r) => r.ref));
|
|
1077
|
+
const noFeedbackCandidates = processableRefs.filter((r) => !signalBearingSet.has(r.ref));
|
|
1078
|
+
let highRetrievalRefs = [];
|
|
1079
|
+
let dbForRetrieval;
|
|
1080
|
+
try {
|
|
1081
|
+
dbForRetrieval = openExistingDatabase();
|
|
1082
|
+
const showEventCount = dbForRetrieval.prepare("SELECT COUNT(*) AS cnt FROM usage_events WHERE event_type = 'show'").get().cnt;
|
|
1083
|
+
if (showEventCount === 0) {
|
|
1084
|
+
warn("Warning: show events not yet in usage_events — zero-feedback fallback will match only search-retrieved assets.");
|
|
1085
|
+
}
|
|
1086
|
+
const retrievalCounts = getRetrievalCounts(dbForRetrieval, noFeedbackCandidates.map((r) => r.ref));
|
|
1087
|
+
highRetrievalRefs = noFeedbackCandidates.filter((r) => (retrievalCounts.get(r.ref) ?? 0) >= RETRIEVAL_COUNT_THRESHOLD);
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
rethrowIfTestIsolationError(err);
|
|
1091
|
+
// best-effort: if DB unavailable, highRetrievalRefs stays empty
|
|
1092
|
+
}
|
|
1093
|
+
finally {
|
|
1094
|
+
if (dbForRetrieval)
|
|
1095
|
+
closeDatabase(dbForRetrieval);
|
|
1096
|
+
}
|
|
1097
|
+
// If the user explicitly scoped to a single ref, always act on it —
|
|
1098
|
+
// skip the signal/retrieval filter entirely. The filter exists to avoid
|
|
1099
|
+
// noisy "improve everything" runs; it should not gate an intentional
|
|
1100
|
+
// per-ref invocation where the user's explicit choice is the signal.
|
|
1101
|
+
//
|
|
1102
|
+
// For type/all scope: only process refs with usage signals (recent feedback
|
|
1103
|
+
// or sufficient retrievals). A stash with no signals has 0 eligible refs —
|
|
1104
|
+
// usage is the gate. Run `akm feedback <ref> --positive` or retrieve assets
|
|
1105
|
+
// to bring them into the eligible pool.
|
|
1106
|
+
const signalAndRetrievalRefs = [...signalFiltered, ...highRetrievalRefs];
|
|
1107
|
+
const mergedRefs = scope.mode === "ref" ? processableRefs : options.requireFeedbackSignal ? signalFiltered : signalAndRetrievalRefs;
|
|
1108
|
+
const utilityMap = buildUtilityMap(mergedRefs);
|
|
1109
|
+
// Load feedback ratio per ref from the pre-computed summary (no extra DB pass).
|
|
1110
|
+
const feedbackRatios = new Map();
|
|
1111
|
+
for (const ref of mergedRefs) {
|
|
1112
|
+
const summary = feedbackSummary.get(ref.ref);
|
|
1113
|
+
const positive = summary?.positive ?? 0;
|
|
1114
|
+
const negative = summary?.negative ?? 0;
|
|
1115
|
+
const total = positive + negative;
|
|
1116
|
+
// ratio = negative proportion (high = needs more improvement)
|
|
1117
|
+
feedbackRatios.set(ref.ref, total > 0 ? negative / total : 0);
|
|
1118
|
+
}
|
|
1119
|
+
// Sort: combine utility (desc) with feedback negativity (desc) — high-negative assets rank higher
|
|
1120
|
+
const sorted = [...mergedRefs].sort((a, b) => {
|
|
1121
|
+
const utilA = utilityMap.get(a.ref) ?? 0;
|
|
1122
|
+
const utilB = utilityMap.get(b.ref) ?? 0;
|
|
1123
|
+
const ratioA = feedbackRatios.get(a.ref) ?? 0;
|
|
1124
|
+
const ratioB = feedbackRatios.get(b.ref) ?? 0;
|
|
1125
|
+
// Combined score: 70% utility, 30% negative ratio
|
|
1126
|
+
const scoreA = utilA * 0.7 + ratioA * 0.3;
|
|
1127
|
+
const scoreB = utilB * 0.7 + ratioB * 0.3;
|
|
1128
|
+
return scoreB - scoreA;
|
|
1129
|
+
});
|
|
1130
|
+
// Phase 0: surface coverage gaps from zero-result search queries
|
|
1131
|
+
let coverageGaps = [];
|
|
1132
|
+
try {
|
|
1133
|
+
const dbForGaps = openExistingDatabase();
|
|
1134
|
+
try {
|
|
1135
|
+
coverageGaps = getZeroResultSearches(dbForGaps);
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
closeDatabase(dbForGaps);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
catch (err) {
|
|
1142
|
+
rethrowIfTestIsolationError(err);
|
|
1143
|
+
// best-effort
|
|
1144
|
+
}
|
|
1145
|
+
// actionableRefs is the post-cooldown, post-validation, post-signal, post-sort
|
|
1146
|
+
// set — i.e. the genuinely processable refs in priority order. Note: this is
|
|
1147
|
+
// a semantic shift from earlier code where actionableRefs was the pre-cooldown
|
|
1148
|
+
// sorted set; the new meaning matches reality and is documented on
|
|
1149
|
+
// ImprovePreparationResult.actionableRefs.
|
|
1150
|
+
//
|
|
1151
|
+
// Final guard: drop any candidate whose backing file is no longer on disk.
|
|
1152
|
+
// Phase 1 validation captures missing files at the start of preparation, but
|
|
1153
|
+
// the gap between that check and dispatch can be minutes on large stashes —
|
|
1154
|
+
// long enough for a checkpoint / git checkout / external cleanup to delete
|
|
1155
|
+
// the asset. Empirically (improve-critical-review 2026-05-20) the single
|
|
1156
|
+
// biggest reject category was "Asset no longer exists on disk" (604/1407 =
|
|
1157
|
+
// 43%), meaning reflect/distill was producing proposals against deleted refs.
|
|
1158
|
+
// A cheap existsSync per surviving candidate eliminates that wasted work.
|
|
1159
|
+
const assetMissingOnDisk = [];
|
|
1160
|
+
const existsCheckedActionable = [];
|
|
1161
|
+
for (const candidate of sorted) {
|
|
1162
|
+
const filePath = await findAssetFilePath(candidate.ref, options.stashDir);
|
|
1163
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
1164
|
+
existsCheckedActionable.push(candidate);
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
assetMissingOnDisk.push(candidate.ref);
|
|
1168
|
+
appendEvent({ eventType: "improve_skipped", ref: candidate.ref, metadata: { reason: "asset_missing_on_disk" } }, eventsCtx);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const actionableRefs = existsCheckedActionable;
|
|
1172
|
+
// Re-split actionableRefs (sorted) into reflect-path vs distill-only-path while
|
|
1173
|
+
// preserving sort order. distillOnlyRefs participate in the sort so --limit
|
|
1174
|
+
// picks them by score, not by arbitrary position.
|
|
1175
|
+
const distillOnlyRefSetForSort = new Set(distillOnlyRefs.map((r) => r.ref));
|
|
1176
|
+
const reflectAndDistillRefsAfterSort = [];
|
|
1177
|
+
const distillOnlyRefsAfterSort = [];
|
|
1178
|
+
for (const r of actionableRefs) {
|
|
1179
|
+
if (distillOnlyRefSetForSort.has(r.ref)) {
|
|
1180
|
+
distillOnlyRefsAfterSort.push(r);
|
|
1181
|
+
}
|
|
1182
|
+
else {
|
|
1183
|
+
reflectAndDistillRefsAfterSort.push(r);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// ── Phase 5: --limit applies to the post-cooldown actionable set ──────────
|
|
1187
|
+
const allLoopRefs = [...reflectAndDistillRefsAfterSort, ...distillOnlyRefsAfterSort];
|
|
1188
|
+
const loopRefs = options.limit ? allLoopRefs.slice(0, options.limit) : allLoopRefs;
|
|
1189
|
+
// Update the returned distillOnlyRefs to the sorted order so callers see the
|
|
1190
|
+
// ranked view (loop stage uses it as a Set so order is irrelevant, but the
|
|
1191
|
+
// shape change keeps downstream consumers consistent).
|
|
1192
|
+
const distillOnlyRefsResult = distillOnlyRefsAfterSort;
|
|
1193
|
+
const totalReflectCooled = fullySkippedCount + distillOnlyRefs.length;
|
|
1194
|
+
if (totalReflectCooled > 0) {
|
|
1195
|
+
info(`[improve] ${totalReflectCooled} of ${preCooldownCount} indexed refs on reflect cooldown ` +
|
|
1196
|
+
`(${fullySkippedCount} fully skipped, ${distillOnlyRefs.length} routed to distill-only)`);
|
|
1197
|
+
}
|
|
1198
|
+
if (signalAndRetrievalRefs.length > 0) {
|
|
1199
|
+
info(`[improve] ${signalAndRetrievalRefs.length} refs with usage signals (${signalFiltered.length} feedback, ${highRetrievalRefs.length} high-retrieval)`);
|
|
1200
|
+
}
|
|
1201
|
+
if (validationFailureRefs.size > 0) {
|
|
1202
|
+
info(`[improve] ${validationFailureRefs.size} with validation failures excluded`);
|
|
1203
|
+
}
|
|
1204
|
+
if (assetMissingOnDisk.length > 0) {
|
|
1205
|
+
info(`[improve] ${assetMissingOnDisk.length} candidates dropped — file not on disk`);
|
|
1206
|
+
}
|
|
1207
|
+
const deferredCount = actionableRefs.length - loopRefs.length;
|
|
1208
|
+
info(`[improve] ${actionableRefs.length} actionable; ${loopRefs.length} will be processed` +
|
|
1209
|
+
(options.limit && deferredCount > 0 ? ` (--limit ${options.limit} applied; ${deferredCount} deferred)` : ""));
|
|
1210
|
+
return {
|
|
1211
|
+
actions,
|
|
1212
|
+
cleanupWarnings,
|
|
1213
|
+
appliedCleanup,
|
|
1214
|
+
memoryIndexHealth,
|
|
1215
|
+
executionLogCandidates,
|
|
1216
|
+
actionableRefs,
|
|
1217
|
+
signalBearingSet,
|
|
1218
|
+
validationFailures,
|
|
1219
|
+
schemaRepairs,
|
|
1220
|
+
lintSummary,
|
|
1221
|
+
loopRefs,
|
|
1222
|
+
distillCooledRefs,
|
|
1223
|
+
distillOnlyRefs: distillOnlyRefsResult,
|
|
1224
|
+
coverageGaps,
|
|
1225
|
+
recentErrors,
|
|
1226
|
+
utilityMap,
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
// TODO(refactor): 13 args including `actions`/`recentErrors` mutation channels. Restructure into immutable plan + mutable context objects — deferred to dedicated refactor with isolated testing.
|
|
1230
|
+
async function runImproveLoopStage(args) {
|
|
1231
|
+
const { scope, options, primaryStashDir, reflectFn, distillFn, loopRefs, actions, signalBearingSet, distillCooledRefs, distillOnlyRefs, recentErrors, rejectedProposalsByRef, utilityMap, startMs, budgetMs, eventsCtx, improveProfile, } = args;
|
|
1232
|
+
// O-1 (#364): compute remaining budget at call time so each sub-call
|
|
1233
|
+
// receives only its fair share of the wall-clock budget.
|
|
1234
|
+
const remainingBudgetMs = () => Math.max(0, budgetMs - (Date.now() - startMs));
|
|
1235
|
+
const RECENT_ERRORS_CAP = 3;
|
|
1236
|
+
// R-2 / #389: Self-Consistency multi-sample voting helpers.
|
|
1237
|
+
// Wang et al. arXiv:2203.11171 — N=3 samples beat single-shot on reasoning tasks.
|
|
1238
|
+
const SC_THRESHOLD = options.selfConsistencyThreshold ?? 0.7;
|
|
1239
|
+
const SC_N = Math.min(Math.max(2, options.selfConsistencyN ?? 3), 5);
|
|
1240
|
+
/**
|
|
1241
|
+
* Compute Jaccard token overlap between two strings.
|
|
1242
|
+
* Tokenizes by whitespace; returns 0 when both are empty.
|
|
1243
|
+
*/
|
|
1244
|
+
function jaccardSimilarity(a, b) {
|
|
1245
|
+
const tokensA = new Set(a.split(/\s+/).filter(Boolean));
|
|
1246
|
+
const tokensB = new Set(b.split(/\s+/).filter(Boolean));
|
|
1247
|
+
if (tokensA.size === 0 && tokensB.size === 0)
|
|
1248
|
+
return 1;
|
|
1249
|
+
let intersection = 0;
|
|
1250
|
+
for (const t of tokensA) {
|
|
1251
|
+
if (tokensB.has(t))
|
|
1252
|
+
intersection++;
|
|
1253
|
+
}
|
|
1254
|
+
const union = tokensA.size + tokensB.size - intersection;
|
|
1255
|
+
return union > 0 ? intersection / union : 0;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Given N reflect results, return the one with the highest average Jaccard
|
|
1259
|
+
* similarity to all other successful results (majority-vote winner).
|
|
1260
|
+
* Falls back to the first successful result when N < 2.
|
|
1261
|
+
*/
|
|
1262
|
+
function pickMajorityVote(results) {
|
|
1263
|
+
const successful = results.filter((r) => r.ok);
|
|
1264
|
+
if (successful.length === 0)
|
|
1265
|
+
return (results[0] ?? {
|
|
1266
|
+
schemaVersion: 1,
|
|
1267
|
+
ok: false,
|
|
1268
|
+
reason: "non_zero_exit",
|
|
1269
|
+
error: "all samples failed",
|
|
1270
|
+
exitCode: null,
|
|
1271
|
+
});
|
|
1272
|
+
if (successful.length === 1)
|
|
1273
|
+
return successful[0];
|
|
1274
|
+
let bestIdx = 0;
|
|
1275
|
+
let bestScore = -1;
|
|
1276
|
+
for (let i = 0; i < successful.length; i++) {
|
|
1277
|
+
let totalSim = 0;
|
|
1278
|
+
for (let j = 0; j < successful.length; j++) {
|
|
1279
|
+
if (i === j)
|
|
1280
|
+
continue;
|
|
1281
|
+
totalSim += jaccardSimilarity(successful[i].proposal.payload.content ?? "", successful[j].proposal.payload.content ?? "");
|
|
1282
|
+
}
|
|
1283
|
+
const avgSim = totalSim / (successful.length - 1);
|
|
1284
|
+
if (avgSim > bestScore) {
|
|
1285
|
+
bestScore = avgSim;
|
|
1286
|
+
bestIdx = i;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return successful[bestIdx] ?? successful[0];
|
|
1290
|
+
}
|
|
1291
|
+
// O-5 / #378: helper to push per-originator errors into the rolling window.
|
|
1292
|
+
function pushRecentError(originator, msg) {
|
|
1293
|
+
if (!recentErrors[originator])
|
|
1294
|
+
recentErrors[originator] = [];
|
|
1295
|
+
recentErrors[originator].push(msg);
|
|
1296
|
+
if (recentErrors[originator].length > RECENT_ERRORS_CAP)
|
|
1297
|
+
recentErrors[originator].shift();
|
|
1298
|
+
}
|
|
1299
|
+
// Build a Set for O(1) membership test — these refs skip the reflect call (Bug D2).
|
|
1300
|
+
const distillOnlyRefSet = new Set(distillOnlyRefs.map((r) => r.ref));
|
|
1301
|
+
let completedCount = 0;
|
|
1302
|
+
let reflectsWithErrorContext = 0;
|
|
1303
|
+
const memoryRefsForInference = new Set();
|
|
1304
|
+
// Pre-load all pending proposals once instead of querying per asset in the loop.
|
|
1305
|
+
const dedupeStashDirForProposals = primaryStashDir ?? options.stashDir;
|
|
1306
|
+
const pendingProposalRefSet = new Set(dedupeStashDirForProposals
|
|
1307
|
+
? listProposals(dedupeStashDirForProposals, { status: "pending" }).map((p) => p.ref)
|
|
1308
|
+
: []);
|
|
1309
|
+
for (const planned of loopRefs) {
|
|
1310
|
+
if (Date.now() - startMs >= budgetMs) {
|
|
1311
|
+
const remaining = loopRefs.length - completedCount;
|
|
1312
|
+
info(`[improve] budget exhausted after ${Math.round((Date.now() - startMs) / 60000)}min — ${remaining} assets skipped`);
|
|
1313
|
+
appendEvent({
|
|
1314
|
+
eventType: "improve_skipped",
|
|
1315
|
+
ref: planned.ref,
|
|
1316
|
+
metadata: {
|
|
1317
|
+
reason: "budget_exhausted",
|
|
1318
|
+
remaining,
|
|
1319
|
+
},
|
|
1320
|
+
}, eventsCtx);
|
|
1321
|
+
// B11: Emit improve_skipped for all remaining assets that will not be processed.
|
|
1322
|
+
for (const remainingRef of loopRefs.slice(completedCount + 1)) {
|
|
1323
|
+
appendEvent({
|
|
1324
|
+
eventType: "improve_skipped",
|
|
1325
|
+
ref: remainingRef.ref,
|
|
1326
|
+
metadata: { reason: "budget_exhausted_batch", remaining: loopRefs.length - completedCount - 1 },
|
|
1327
|
+
}, eventsCtx);
|
|
1328
|
+
}
|
|
1329
|
+
actions.push({
|
|
1330
|
+
ref: planned.ref,
|
|
1331
|
+
mode: "error",
|
|
1332
|
+
result: { ok: false, error: "timeout: improve wall-clock budget exhausted" },
|
|
1333
|
+
});
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
// Bug D2: distillOnlyRefs skip the reflect call but still run the distill path.
|
|
1338
|
+
// Bug D1: in-loop distill-cooldown check removed — distill-cooled candidates
|
|
1339
|
+
// have their synthetic actions emitted in runImprovePreparationStage.
|
|
1340
|
+
const isDistillOnly = distillOnlyRefSet.has(planned.ref);
|
|
1341
|
+
const parsedPlannedRef = parseAssetRef(planned.ref);
|
|
1342
|
+
// B6: derived memories are machine-generated; skip reflect to avoid noisy proposals.
|
|
1343
|
+
// shouldDistillMemoryRef already returns false for .derived refs, so the distill
|
|
1344
|
+
// path is also a no-op for them — we just avoid unnecessary agent spawns.
|
|
1345
|
+
// D2: distillOnlyRefs also skip the reflect call (reflect-cooled, distill path only).
|
|
1346
|
+
if (!isDistillOnly && !planned.ref.endsWith(".derived")) {
|
|
1347
|
+
// Type guard: skip reflect for unsupported types (script, vault, task, etc.)
|
|
1348
|
+
// and raw wiki directories, driven by the active improve profile.
|
|
1349
|
+
const reflectSkip = shouldSkipRef(planned.ref, "reflect", improveProfile);
|
|
1350
|
+
if (reflectSkip.skip) {
|
|
1351
|
+
actions.push({
|
|
1352
|
+
ref: planned.ref,
|
|
1353
|
+
mode: "reflect-skipped",
|
|
1354
|
+
result: { ok: true, reason: reflectSkip.reason },
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
// O-5 / #378: only inject reflect-originator errors into the reflect call.
|
|
1359
|
+
// Cross-task errors (e.g. schema-repair) must NOT contaminate reflect prompts.
|
|
1360
|
+
const reflectErrors = recentErrors.reflect ?? [];
|
|
1361
|
+
if (reflectErrors.length > 0)
|
|
1362
|
+
reflectsWithErrorContext++;
|
|
1363
|
+
// O-1 (#364): pass remaining budget as timeoutMs so the agent spawn is
|
|
1364
|
+
// bounded by the wall-clock deadline rather than the default per-profile timeout.
|
|
1365
|
+
const reflectBudgetMs = remainingBudgetMs();
|
|
1366
|
+
// Wire profile.processes.reflect.{mode, profile, timeoutMs} into the reflect
|
|
1367
|
+
// dispatch when present. Falls back to akmReflect's own config-based resolution
|
|
1368
|
+
// (profiles.improve.<name>.processes.reflect → defaults.llm) when the profile
|
|
1369
|
+
// does not specify.
|
|
1370
|
+
const reflectProfileRunner = resolveImproveProcessRunnerFromProfile(improveProfile.processes?.reflect, options.config ?? loadConfig());
|
|
1371
|
+
const reflectCallArgs = {
|
|
1372
|
+
ref: planned.ref,
|
|
1373
|
+
task: options.task,
|
|
1374
|
+
...(options.stashDir ? { stashDir: options.stashDir } : {}),
|
|
1375
|
+
...(reflectErrors.length > 0 ? { avoidPatterns: [...reflectErrors] } : {}),
|
|
1376
|
+
agentProcess: options.agentProcess ?? "reflect",
|
|
1377
|
+
eventSource: "improve",
|
|
1378
|
+
...(reflectBudgetMs > 0 ? { timeoutMs: reflectBudgetMs } : {}),
|
|
1379
|
+
...(reflectProfileRunner ? { runner: reflectProfileRunner } : {}),
|
|
1380
|
+
};
|
|
1381
|
+
// R-2 / #389: Self-consistency multi-sample voting for high-utility refs.
|
|
1382
|
+
// Self-Consistency arXiv:2203.11171 — N=3 samples beat single-shot quality.
|
|
1383
|
+
const refUtility = utilityMap.get(planned.ref) ?? 0;
|
|
1384
|
+
const useConsistency = refUtility >= SC_THRESHOLD && SC_N >= 2;
|
|
1385
|
+
let reflectResult;
|
|
1386
|
+
if (useConsistency) {
|
|
1387
|
+
const samples = [];
|
|
1388
|
+
for (let s = 0; s < SC_N; s++) {
|
|
1389
|
+
if (remainingBudgetMs() <= 0)
|
|
1390
|
+
break;
|
|
1391
|
+
// draftMode: skip DB write so each sample doesn't create a proposal.
|
|
1392
|
+
samples.push(await reflectFn({ ...reflectCallArgs, draftMode: true }));
|
|
1393
|
+
}
|
|
1394
|
+
const winner = pickMajorityVote(samples.length > 0 ? samples : [await reflectFn({ ...reflectCallArgs, draftMode: true })]);
|
|
1395
|
+
// Persist only the majority-vote winner as a single real proposal.
|
|
1396
|
+
if (winner.ok && primaryStashDir) {
|
|
1397
|
+
const persistResult = createProposal(primaryStashDir, {
|
|
1398
|
+
ref: winner.proposal.ref,
|
|
1399
|
+
source: "reflect",
|
|
1400
|
+
sourceRun: `reflect-sc-${Date.now()}`,
|
|
1401
|
+
payload: winner.proposal.payload,
|
|
1402
|
+
});
|
|
1403
|
+
reflectResult = isProposalSkipped(persistResult)
|
|
1404
|
+
? {
|
|
1405
|
+
schemaVersion: 1,
|
|
1406
|
+
ok: false,
|
|
1407
|
+
reason: "cooldown",
|
|
1408
|
+
error: `SC proposal skipped: ${persistResult.message}`,
|
|
1409
|
+
ref: winner.ref,
|
|
1410
|
+
exitCode: null,
|
|
1411
|
+
}
|
|
1412
|
+
: { ...winner, proposal: persistResult };
|
|
1413
|
+
}
|
|
1414
|
+
else {
|
|
1415
|
+
reflectResult = winner;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
else {
|
|
1419
|
+
reflectResult = await reflectFn(reflectCallArgs);
|
|
1420
|
+
}
|
|
1421
|
+
const isCooldown = !reflectResult.ok && reflectResult.reason === "cooldown";
|
|
1422
|
+
actions.push({
|
|
1423
|
+
ref: planned.ref,
|
|
1424
|
+
mode: reflectResult.ok ? "reflect" : isCooldown ? "reflect-cooldown" : "reflect-failed",
|
|
1425
|
+
result: reflectResult,
|
|
1426
|
+
});
|
|
1427
|
+
// Cooldown skips are not failures — do not pollute recentErrors with them
|
|
1428
|
+
// (those get injected as `avoidPatterns` into the next reflect prompt).
|
|
1429
|
+
if (!reflectResult.ok && !isCooldown) {
|
|
1430
|
+
const errMsg = reflectResult.error ?? reflectResult.reason ?? "unknown reflect error";
|
|
1431
|
+
pushRecentError("reflect", errMsg);
|
|
1432
|
+
}
|
|
1433
|
+
// improve_reflect_outcome — per-asset metric for tuning the reflect path.
|
|
1434
|
+
appendEvent({
|
|
1435
|
+
eventType: "improve_reflect_outcome",
|
|
1436
|
+
ref: planned.ref,
|
|
1437
|
+
metadata: {
|
|
1438
|
+
ok: reflectResult.ok,
|
|
1439
|
+
durationMs: reflectResult.ok ? reflectResult.durationMs : undefined,
|
|
1440
|
+
agentProfile: reflectResult.ok ? reflectResult.agentProfile : undefined,
|
|
1441
|
+
reason: reflectResult.ok ? undefined : reflectResult.reason,
|
|
1442
|
+
},
|
|
1443
|
+
}, eventsCtx);
|
|
1444
|
+
// Phase 6A (Advantage D6a): Confidence-driven auto-accept.
|
|
1445
|
+
//
|
|
1446
|
+
// The existing `--auto-accept` flag (commit 0c5eaa1) is a 0-100 integer
|
|
1447
|
+
// threshold; `undefined` means auto-accept is disabled. Until this
|
|
1448
|
+
// wave, the consolidate path treated any non-undefined value as a
|
|
1449
|
+
// whole-batch "safe" accept (see TODO in consolidate.ts). Now that
|
|
1450
|
+
// reflect proposals carry a self-reported `confidence` score in
|
|
1451
|
+
// [0, 1], we can compare per-proposal: confidence >= threshold/100
|
|
1452
|
+
// auto-accepts via the standard `promoteProposal` path; otherwise the
|
|
1453
|
+
// proposal waits in the pending queue for human review.
|
|
1454
|
+
//
|
|
1455
|
+
// Plan default is 0.8 (per `self-improvement-enhancements-plan.md`
|
|
1456
|
+
// line 184). We honour the existing CLI default (90 → 0.9) because
|
|
1457
|
+
// that is what users have been seeing since 0c5eaa1; the plan and the
|
|
1458
|
+
// existing default agree directionally that high-confidence-only is
|
|
1459
|
+
// the right policy. The CLI flag is the single knob.
|
|
1460
|
+
if (reflectResult.ok &&
|
|
1461
|
+
options.autoAccept !== undefined &&
|
|
1462
|
+
typeof reflectResult.proposal.confidence === "number" &&
|
|
1463
|
+
primaryStashDir) {
|
|
1464
|
+
const threshold = options.autoAccept / 100;
|
|
1465
|
+
const confidence = reflectResult.proposal.confidence;
|
|
1466
|
+
if (confidence >= threshold) {
|
|
1467
|
+
try {
|
|
1468
|
+
const cfg = options.config ?? loadConfig();
|
|
1469
|
+
const promotion = await promoteProposal(primaryStashDir, cfg, reflectResult.proposal.id, {}, undefined);
|
|
1470
|
+
appendEvent({
|
|
1471
|
+
eventType: "promoted",
|
|
1472
|
+
ref: promotion.ref,
|
|
1473
|
+
metadata: {
|
|
1474
|
+
proposalId: promotion.proposal.id,
|
|
1475
|
+
source: promotion.proposal.source,
|
|
1476
|
+
...(promotion.proposal.sourceRun !== undefined
|
|
1477
|
+
? { sourceRun: promotion.proposal.sourceRun }
|
|
1478
|
+
: {}),
|
|
1479
|
+
assetPath: promotion.assetPath,
|
|
1480
|
+
autoAccept: true,
|
|
1481
|
+
confidence,
|
|
1482
|
+
threshold,
|
|
1483
|
+
},
|
|
1484
|
+
}, eventsCtx);
|
|
1485
|
+
info(`[improve] auto-accepted ${promotion.ref} (confidence=${confidence.toFixed(2)} >= threshold=${threshold.toFixed(2)})`);
|
|
1486
|
+
}
|
|
1487
|
+
catch (err) {
|
|
1488
|
+
// Auto-accept failures (validation, write error, etc.) must not
|
|
1489
|
+
// poison the loop — surface a warning and leave the proposal
|
|
1490
|
+
// pending so the reviewer can deal with it manually.
|
|
1491
|
+
warn(`[improve] auto-accept failed for ${reflectResult.proposal.ref}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
} // end else (reflect type/profile check)
|
|
1496
|
+
}
|
|
1497
|
+
else if (!isDistillOnly && planned.ref.endsWith(".derived")) {
|
|
1498
|
+
// B6: .derived refs skip reflect; record synthetic skip action.
|
|
1499
|
+
actions.push({
|
|
1500
|
+
ref: planned.ref,
|
|
1501
|
+
mode: "distill-skipped",
|
|
1502
|
+
result: { ok: true, reason: "derived-memory-reflect-skipped" },
|
|
1503
|
+
});
|
|
1504
|
+
appendEvent({
|
|
1505
|
+
eventType: "improve_skipped",
|
|
1506
|
+
ref: planned.ref,
|
|
1507
|
+
metadata: { reason: "derived_memory_reflect_skipped" },
|
|
1508
|
+
}, eventsCtx);
|
|
1509
|
+
}
|
|
1510
|
+
// isDistillOnly refs: no reflect action emitted — proceed directly to distill path below.
|
|
1511
|
+
const hasRecentFeedbackSignal = signalBearingSet.has(planned.ref);
|
|
1512
|
+
const explicitRefScope = scope.mode === "ref";
|
|
1513
|
+
// Profile gate: apply the full type-filter / raw-wiki / disabled rules to
|
|
1514
|
+
// distill so callers who configure `profile.processes.distill.allowedTypes`
|
|
1515
|
+
// or land on raw-wiki refs get a recorded skip action instead of silently
|
|
1516
|
+
// proceeding.
|
|
1517
|
+
const distillSkip = shouldSkipRef(planned.ref, "distill", improveProfile);
|
|
1518
|
+
if (distillSkip.skip) {
|
|
1519
|
+
actions.push({
|
|
1520
|
+
ref: planned.ref,
|
|
1521
|
+
mode: "distill-skipped",
|
|
1522
|
+
result: { ok: true, reason: distillSkip.reason },
|
|
1523
|
+
});
|
|
1524
|
+
completedCount++;
|
|
1525
|
+
info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
// See `isDistillCandidateRef` — excludes `lesson:*` (and anything else in
|
|
1529
|
+
// DISTILL_REFUSED_INPUT_TYPES) so distill never gets queued for an input
|
|
1530
|
+
// it will refuse.
|
|
1531
|
+
const shouldAttemptDistill = isDistillCandidateRef(planned.ref, options.stashDir);
|
|
1532
|
+
const skipMemoryDistillForWeakSignal = !isDistillOnly && parsedPlannedRef.type === "memory" && !hasRecentFeedbackSignal && !explicitRefScope;
|
|
1533
|
+
// distillCooledRefs guard: pre-filter emitted synthetic actions for distill-candidate
|
|
1534
|
+
// refs; non-candidate refs in the set are blocked here.
|
|
1535
|
+
// O-2 (#365): bypass the distill cooldown when the user explicitly targeted
|
|
1536
|
+
// this ref via --scope — their intent overrides unattended-run policies.
|
|
1537
|
+
if (shouldAttemptDistill &&
|
|
1538
|
+
!skipMemoryDistillForWeakSignal &&
|
|
1539
|
+
(!distillCooledRefs.has(planned.ref) || explicitRefScope)) {
|
|
1540
|
+
// TODO(refactor): single call site needs both lesson+knowledge refs for proposal dedup. If a third target ref type is added, extract deriveAllTargetRefs(inputRef): string[].
|
|
1541
|
+
const lessonRef = deriveLessonRef(planned.ref);
|
|
1542
|
+
const knowledgeRef = deriveKnowledgeRef(planned.ref);
|
|
1543
|
+
const dedupeStashDir = primaryStashDir ?? options.stashDir;
|
|
1544
|
+
if (dedupeStashDir) {
|
|
1545
|
+
// B2: check both lesson ref and knowledge ref since auto-promoted memories
|
|
1546
|
+
// create knowledge: proposals, not lesson: proposals.
|
|
1547
|
+
const hasExistingPending = pendingProposalRefSet.has(lessonRef) || pendingProposalRefSet.has(knowledgeRef);
|
|
1548
|
+
if (hasExistingPending) {
|
|
1549
|
+
actions.push({
|
|
1550
|
+
ref: planned.ref,
|
|
1551
|
+
mode: "distill-skipped",
|
|
1552
|
+
result: { ok: true, reason: "pending proposal exists" },
|
|
1553
|
+
});
|
|
1554
|
+
appendEvent({
|
|
1555
|
+
eventType: "improve_skipped",
|
|
1556
|
+
ref: planned.ref,
|
|
1557
|
+
metadata: { reason: "pending_proposal_exists" },
|
|
1558
|
+
}, eventsCtx);
|
|
1559
|
+
completedCount++;
|
|
1560
|
+
info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
// D-2 (#370): reject-aware cooldown for distill. When the reviewer
|
|
1564
|
+
// recently rejected a distilled lesson or knowledge proposal for this
|
|
1565
|
+
// asset, skip re-distillation for DISTILL_REJECT_COOLDOWN_DAYS.
|
|
1566
|
+
// Prevents the same rejected proposal from returning the next day.
|
|
1567
|
+
// References: ExpeL arXiv:2308.10144, STaR arXiv:2203.14465.
|
|
1568
|
+
const DISTILL_REJECT_COOLDOWN_MS = daysToMs(options.distillCooldownDays ?? 1);
|
|
1569
|
+
const recentlyRejectedLesson = DISTILL_REJECT_COOLDOWN_MS > 0 &&
|
|
1570
|
+
!explicitRefScope && // O-2: bypass when --scope <ref> is explicit
|
|
1571
|
+
(rejectedProposalsByRef.has(lessonRef) || rejectedProposalsByRef.has(knowledgeRef));
|
|
1572
|
+
if (recentlyRejectedLesson) {
|
|
1573
|
+
const rejectedEntry = rejectedProposalsByRef.get(lessonRef) ?? rejectedProposalsByRef.get(knowledgeRef);
|
|
1574
|
+
const rejectedAgeMs = rejectedEntry ? Date.now() - new Date(rejectedEntry.ts).getTime() : 0;
|
|
1575
|
+
if (rejectedAgeMs < DISTILL_REJECT_COOLDOWN_MS) {
|
|
1576
|
+
actions.push({
|
|
1577
|
+
ref: planned.ref,
|
|
1578
|
+
mode: "distill-skipped",
|
|
1579
|
+
result: { ok: true, reason: "distill reject cooldown" },
|
|
1580
|
+
});
|
|
1581
|
+
appendEvent({
|
|
1582
|
+
eventType: "improve_skipped",
|
|
1583
|
+
ref: planned.ref,
|
|
1584
|
+
metadata: {
|
|
1585
|
+
reason: "distill_reject_cooldown",
|
|
1586
|
+
cooldownDays: options.distillCooldownDays ?? 1,
|
|
1587
|
+
},
|
|
1588
|
+
}, eventsCtx);
|
|
1589
|
+
completedCount++;
|
|
1590
|
+
info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
const distillResult = await distillFn({
|
|
1596
|
+
ref: planned.ref,
|
|
1597
|
+
...(parsedPlannedRef.type === "memory" ? { proposalKind: "auto" } : {}),
|
|
1598
|
+
...(options.stashDir ? { stashDir: options.stashDir } : {}),
|
|
1599
|
+
});
|
|
1600
|
+
actions.push({ ref: planned.ref, mode: "distill", result: distillResult });
|
|
1601
|
+
if (parsedPlannedRef.type === "memory") {
|
|
1602
|
+
const promotedToKnowledge = distillResult.outcome === "queued" && distillResult.proposalKind === "knowledge";
|
|
1603
|
+
if (!promotedToKnowledge)
|
|
1604
|
+
memoryRefsForInference.add(planned.ref);
|
|
1605
|
+
}
|
|
1606
|
+
if (distillResult.outcome === "quality_rejected" && primaryStashDir) {
|
|
1607
|
+
const slug = planned.ref
|
|
1608
|
+
.replace(/[^a-z0-9]/gi, "-")
|
|
1609
|
+
.toLowerCase()
|
|
1610
|
+
.slice(0, 60);
|
|
1611
|
+
writeEvalCase(primaryStashDir, {
|
|
1612
|
+
ref: planned.ref,
|
|
1613
|
+
failureReason: distillResult.reason ?? "quality gate rejected",
|
|
1614
|
+
assetType: parseAssetRef(planned.ref).type ?? "unknown",
|
|
1615
|
+
rejectedAt: Date.now(),
|
|
1616
|
+
source: "distill_quality_rejected",
|
|
1617
|
+
slug: `${slug}-${Date.now()}`,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
// D6: use pre-loaded map instead of per-iteration DB query
|
|
1621
|
+
const rejectedProposalEvent = rejectedProposalsByRef.get(planned.ref);
|
|
1622
|
+
if (rejectedProposalEvent && primaryStashDir) {
|
|
1623
|
+
const slug = planned.ref
|
|
1624
|
+
.replace(/[^a-z0-9]/gi, "-")
|
|
1625
|
+
.toLowerCase()
|
|
1626
|
+
.slice(0, 60);
|
|
1627
|
+
writeEvalCase(primaryStashDir, {
|
|
1628
|
+
ref: planned.ref,
|
|
1629
|
+
failureReason: rejectedProposalEvent.metadata?.reason ?? "proposal rejected",
|
|
1630
|
+
assetType: parseAssetRef(planned.ref).type ?? "unknown",
|
|
1631
|
+
rejectedAt: new Date(rejectedProposalEvent.ts).getTime(),
|
|
1632
|
+
source: "proposal_rejected",
|
|
1633
|
+
slug: `${slug}-rejected`,
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
else if (skipMemoryDistillForWeakSignal) {
|
|
1638
|
+
actions.push({
|
|
1639
|
+
ref: planned.ref,
|
|
1640
|
+
mode: "distill-skipped",
|
|
1641
|
+
result: { ok: true, reason: "memory requires recent feedback signal" },
|
|
1642
|
+
});
|
|
1643
|
+
appendEvent({
|
|
1644
|
+
eventType: "improve_skipped",
|
|
1645
|
+
ref: planned.ref,
|
|
1646
|
+
metadata: { reason: "memory_distill_requires_feedback" },
|
|
1647
|
+
}, eventsCtx);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
catch (err) {
|
|
1651
|
+
// B7: UsageError thrown by akmDistill on validation_failed should be recorded
|
|
1652
|
+
// as mode:"distill" with outcome:"validation_failed", NOT as a generic error.
|
|
1653
|
+
// The distill_invoked event was already emitted inside akmDistill before the throw.
|
|
1654
|
+
if (err instanceof UsageError) {
|
|
1655
|
+
actions.push({
|
|
1656
|
+
ref: planned.ref,
|
|
1657
|
+
mode: "distill",
|
|
1658
|
+
result: { ok: false, outcome: "validation_failed", error: err.message },
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
else {
|
|
1662
|
+
actions.push({
|
|
1663
|
+
ref: planned.ref,
|
|
1664
|
+
mode: "error",
|
|
1665
|
+
result: { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
completedCount++;
|
|
1670
|
+
info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
|
|
1671
|
+
}
|
|
1672
|
+
return { reflectsWithErrorContext, memoryRefsForInference };
|
|
1673
|
+
}
|
|
1674
|
+
async function runImprovePostLoopStage(args) {
|
|
1675
|
+
const { scope, options, primaryStashDir, actionableRefs, appliedCleanup, cleanupWarnings, memorySummary, memoryRefsForInference, reindexFn, eventsCtx, budgetSignal, improveProfile, } = args;
|
|
1676
|
+
const allWarnings = [...cleanupWarnings, ...(appliedCleanup?.warnings ?? [])];
|
|
1677
|
+
const baseConfig = options.config ?? loadConfig();
|
|
1678
|
+
const MEMORY_VOLUME_THRESHOLD = options.memoryVolumeConsolidationThreshold ?? 100;
|
|
1679
|
+
const hasLlm = !!(baseConfig.defaults?.llm || baseConfig.defaults?.agent);
|
|
1680
|
+
const volumeTriggered = typeof memorySummary.eligible === "number" && memorySummary.eligible > MEMORY_VOLUME_THRESHOLD && hasLlm;
|
|
1681
|
+
// When volume triggers a consolidation pass, force-enable the consolidate
|
|
1682
|
+
// process on the default improve profile so the gate accepts the run even
|
|
1683
|
+
// if the user's config disabled it. We synthesise a new profile override
|
|
1684
|
+
// rather than mutating connection settings.
|
|
1685
|
+
const consolidationConfig = volumeTriggered
|
|
1686
|
+
? {
|
|
1687
|
+
...baseConfig,
|
|
1688
|
+
profiles: {
|
|
1689
|
+
...(baseConfig.profiles ?? {}),
|
|
1690
|
+
improve: {
|
|
1691
|
+
...(baseConfig.profiles?.improve ?? {}),
|
|
1692
|
+
default: {
|
|
1693
|
+
...(baseConfig.profiles?.improve?.default ?? {}),
|
|
1694
|
+
processes: {
|
|
1695
|
+
...(baseConfig.profiles?.improve?.default?.processes ?? {}),
|
|
1696
|
+
consolidate: {
|
|
1697
|
+
...(baseConfig.profiles?.improve?.default?.processes?.consolidate ?? {}),
|
|
1698
|
+
enabled: true,
|
|
1699
|
+
},
|
|
1700
|
+
},
|
|
1701
|
+
},
|
|
1702
|
+
},
|
|
1703
|
+
},
|
|
1704
|
+
}
|
|
1705
|
+
: baseConfig;
|
|
1706
|
+
// TODO(refactor): the reflect/distill cooldown above and this consolidation gate share the "is the most recent event of type X within window" pattern. Unifying would muddy the per-ref tier logic above — defer.
|
|
1707
|
+
const consolidateCooldownDays = options.consolidateCooldownDays ?? 14;
|
|
1708
|
+
const CONSOLIDATE_COOLDOWN_MS = daysToMs(consolidateCooldownDays);
|
|
1709
|
+
const consolidationCutoff = new Date(Date.now() - CONSOLIDATE_COOLDOWN_MS).toISOString();
|
|
1710
|
+
const recentConsolidations = readEvents({ type: "consolidate_completed", since: consolidationCutoff });
|
|
1711
|
+
const lastConsolidation = recentConsolidations.events
|
|
1712
|
+
.filter((e) => e.metadata?.processed && Number(e.metadata.processed) > 0)
|
|
1713
|
+
.sort((a, b) => new Date(b.ts ?? 0).getTime() - new Date(a.ts ?? 0).getTime())[0];
|
|
1714
|
+
const consolidationOnCooldown = !volumeTriggered &&
|
|
1715
|
+
consolidateCooldownDays > 0 &&
|
|
1716
|
+
lastConsolidation?.ts &&
|
|
1717
|
+
Date.now() - new Date(lastConsolidation.ts).getTime() < CONSOLIDATE_COOLDOWN_MS;
|
|
1718
|
+
// Profile gate: if profile explicitly disables consolidate, skip the entire pass.
|
|
1719
|
+
const consolidateDisabledByProfile = improveProfile?.processes?.consolidate?.enabled === false;
|
|
1720
|
+
let consolidation = {
|
|
1721
|
+
schemaVersion: 1,
|
|
1722
|
+
ok: true,
|
|
1723
|
+
shape: "consolidate-result",
|
|
1724
|
+
dryRun: false,
|
|
1725
|
+
previewOnly: false,
|
|
1726
|
+
target: "",
|
|
1727
|
+
processed: 0,
|
|
1728
|
+
merged: 0,
|
|
1729
|
+
deleted: 0,
|
|
1730
|
+
promoted: [],
|
|
1731
|
+
contradicted: 0,
|
|
1732
|
+
warnings: [],
|
|
1733
|
+
durationMs: 0,
|
|
1734
|
+
};
|
|
1735
|
+
if (consolidateDisabledByProfile) {
|
|
1736
|
+
info("[improve] consolidation skipped (disabled by improve profile)");
|
|
1737
|
+
}
|
|
1738
|
+
else if (!consolidationOnCooldown) {
|
|
1739
|
+
consolidation = await akmConsolidate({
|
|
1740
|
+
...options.consolidateOptions,
|
|
1741
|
+
config: consolidationConfig,
|
|
1742
|
+
stashDir: options.stashDir,
|
|
1743
|
+
autoTriggered: volumeTriggered,
|
|
1744
|
+
// Honor profile.autoAccept (already merged into options.autoAccept at the
|
|
1745
|
+
// top of akmImprove). The CLI parser always supplies 90 when --auto-accept
|
|
1746
|
+
// is absent, so ?? 90 is not needed here and would prevent --auto-accept=false
|
|
1747
|
+
// (which maps to undefined) from disabling consolidation auto-accept.
|
|
1748
|
+
// options.consolidateOptions.autoAccept (if explicitly provided by caller)
|
|
1749
|
+
// still wins because the spread above runs first.
|
|
1750
|
+
autoAccept: options.consolidateOptions?.autoAccept ?? options.autoAccept,
|
|
1751
|
+
});
|
|
1752
|
+
if (consolidation.processed > 0) {
|
|
1753
|
+
appendEvent({
|
|
1754
|
+
eventType: "consolidate_completed",
|
|
1755
|
+
ref: "memory:_consolidation",
|
|
1756
|
+
metadata: { processed: consolidation.processed, merged: consolidation.merged },
|
|
1757
|
+
}, eventsCtx);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
else {
|
|
1761
|
+
const daysAgo = Math.round((Date.now() - new Date(lastConsolidation?.ts ?? 0).getTime()) / 86400000);
|
|
1762
|
+
appendEvent({
|
|
1763
|
+
eventType: "improve_skipped",
|
|
1764
|
+
ref: "memory:_consolidation",
|
|
1765
|
+
metadata: {
|
|
1766
|
+
reason: "consolidation_cooldown",
|
|
1767
|
+
cooldownDays: consolidateCooldownDays,
|
|
1768
|
+
lastEventTs: lastConsolidation?.ts ?? null,
|
|
1769
|
+
},
|
|
1770
|
+
}, eventsCtx);
|
|
1771
|
+
info(`[improve] consolidation skipped (last ran ${daysAgo}d ago, cooldown ${consolidateCooldownDays}d)`);
|
|
1772
|
+
}
|
|
1773
|
+
// D9: track whether consolidation wrote any data so graph extraction can reindex if needed
|
|
1774
|
+
const consolidationRan = !consolidateDisabledByProfile && !consolidationOnCooldown && consolidation.processed > 0;
|
|
1775
|
+
info("[improve] post-loop maintenance starting");
|
|
1776
|
+
const maintenanceResult = await runImproveMaintenancePasses({
|
|
1777
|
+
options,
|
|
1778
|
+
primaryStashDir,
|
|
1779
|
+
actionableRefs,
|
|
1780
|
+
memoryRefsForInference,
|
|
1781
|
+
allWarnings,
|
|
1782
|
+
reindexFn,
|
|
1783
|
+
consolidationRan,
|
|
1784
|
+
// O-1 (#364): forward the budget signal to memory inference + graph extraction.
|
|
1785
|
+
budgetSignal,
|
|
1786
|
+
eventsCtx,
|
|
1787
|
+
improveProfile,
|
|
1788
|
+
});
|
|
1789
|
+
let deadUrls;
|
|
1790
|
+
if (scope.mode === "all" && primaryStashDir && actionableRefs.length > 0) {
|
|
1791
|
+
try {
|
|
1792
|
+
const knowledgeEntries = actionableRefs
|
|
1793
|
+
.filter((r) => {
|
|
1794
|
+
try {
|
|
1795
|
+
return parseAssetRef(r.ref).type === "knowledge";
|
|
1796
|
+
}
|
|
1797
|
+
catch {
|
|
1798
|
+
return false;
|
|
1799
|
+
}
|
|
1800
|
+
})
|
|
1801
|
+
.slice(0, 10)
|
|
1802
|
+
.map((r) => ({ ref: r.ref, body: "" }));
|
|
1803
|
+
if (knowledgeEntries.length > 0) {
|
|
1804
|
+
info(`[improve] checking URLs in ${knowledgeEntries.length} knowledge refs`);
|
|
1805
|
+
deadUrls = await checkDeadUrls(primaryStashDir, knowledgeEntries);
|
|
1806
|
+
info(`[improve] URL check complete (${deadUrls.length} dead/timeout URLs)`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
catch {
|
|
1810
|
+
// best-effort
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
return {
|
|
1814
|
+
allWarnings,
|
|
1815
|
+
consolidation,
|
|
1816
|
+
deadUrls,
|
|
1817
|
+
...(maintenanceResult.memoryInference ? { memoryInference: maintenanceResult.memoryInference } : {}),
|
|
1818
|
+
...(maintenanceResult.graphExtraction ? { graphExtraction: maintenanceResult.graphExtraction } : {}),
|
|
1819
|
+
...(maintenanceResult.stalenessDetection ? { stalenessDetection: maintenanceResult.stalenessDetection } : {}),
|
|
1820
|
+
...(maintenanceResult.actions && maintenanceResult.actions.length > 0
|
|
1821
|
+
? { maintenanceActions: maintenanceResult.actions }
|
|
1822
|
+
: {}),
|
|
1823
|
+
memoryInferenceDurationMs: maintenanceResult.memoryInferenceDurationMs,
|
|
1824
|
+
graphExtractionDurationMs: maintenanceResult.graphExtractionDurationMs,
|
|
1825
|
+
orphansPurged: maintenanceResult.orphansPurged,
|
|
1826
|
+
proposalsExpired: maintenanceResult.proposalsExpired,
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
// TODO(refactor): mutates the passed-in `allWarnings` array as a hidden side channel. Return warnings in ImproveMaintenanceResult and merge in caller — invasive signature change deferred to next refactor pass.
|
|
1830
|
+
async function runImproveMaintenancePasses(args) {
|
|
1831
|
+
const { options, primaryStashDir, memoryRefsForInference, allWarnings, reindexFn, consolidationRan, budgetSignal, eventsCtx, improveProfile, } = args;
|
|
1832
|
+
if (!primaryStashDir)
|
|
1833
|
+
return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
|
|
1834
|
+
const config = options.config ?? loadConfig();
|
|
1835
|
+
const sources = resolveSourceEntries(options.stashDir, config);
|
|
1836
|
+
const memoryInferenceFn = options.memoryInferenceFn ?? runMemoryInferencePass;
|
|
1837
|
+
const graphExtractionFn = options.graphExtractionFn ?? runGraphExtractionPass;
|
|
1838
|
+
const stalenessDetectionFn = options.stalenessDetectionFn ?? runStalenessDetectionPass;
|
|
1839
|
+
let db;
|
|
1840
|
+
let memoryInference;
|
|
1841
|
+
let graphExtraction;
|
|
1842
|
+
let stalenessDetection;
|
|
1843
|
+
let reindexedAfterInference = false;
|
|
1844
|
+
const actions = [];
|
|
1845
|
+
let memoryInferenceDurationMs = 0;
|
|
1846
|
+
let graphExtractionDurationMs = 0;
|
|
1847
|
+
let orphansPurged = 0;
|
|
1848
|
+
let proposalsExpired = 0;
|
|
1849
|
+
try {
|
|
1850
|
+
db = openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
|
|
1851
|
+
// Memory inference candidate-discovery (post-Item 9 fix from
|
|
1852
|
+
// memory:akm-improve-critical-review-2026-05-20). Previously this pass
|
|
1853
|
+
// was gated on memoryRefsForInference.size > 0 AND passed those refs as a
|
|
1854
|
+
// candidateRefs filter. But memoryRefsForInference is populated from refs
|
|
1855
|
+
// distilled THIS RUN — by the time that happens, those parents are
|
|
1856
|
+
// already split (`inferenceProcessed: true`) and `isPendingMemory` excludes
|
|
1857
|
+
// them. The genuinely-pending parents in the stash never entered the
|
|
1858
|
+
// filter. Result: 0/0/0 for 25 consecutive runs.
|
|
1859
|
+
//
|
|
1860
|
+
// Fix: always run the pass when the feature is enabled; let the pass's
|
|
1861
|
+
// own `collectPendingMemories` + `isPendingMemory` predicate find
|
|
1862
|
+
// candidates from the filesystem-of-truth. The this-run set is still
|
|
1863
|
+
// logged as a hint but no longer used as a filter.
|
|
1864
|
+
const memoryInferenceDisabledByProfile = improveProfile?.processes?.memoryInference?.enabled === false;
|
|
1865
|
+
if (memoryInferenceDisabledByProfile) {
|
|
1866
|
+
info("[improve] memory inference skipped (disabled by improve profile)");
|
|
1867
|
+
}
|
|
1868
|
+
else {
|
|
1869
|
+
const hintRefs = memoryRefsForInference.size;
|
|
1870
|
+
info(hintRefs > 0
|
|
1871
|
+
? `[improve] memory inference starting (${hintRefs} hint refs touched this run; pass discovers all pending)`
|
|
1872
|
+
: "[improve] memory inference starting (discovering pending parents)");
|
|
1873
|
+
const inferenceStart = Date.now();
|
|
1874
|
+
try {
|
|
1875
|
+
// O-1 (#364): pass budget signal so a hung inference call is cancelled.
|
|
1876
|
+
memoryInference = await memoryInferenceFn(config, sources, budgetSignal, db, false, (event) => {
|
|
1877
|
+
const current = event.currentRef ? ` ${event.currentRef}` : "";
|
|
1878
|
+
info(`[improve] memory inference ${event.processed}/${event.total}${current} (written ${event.writtenFacts}, skipped ${event.skippedNoFacts})`);
|
|
1879
|
+
});
|
|
1880
|
+
memoryInferenceDurationMs = Date.now() - inferenceStart;
|
|
1881
|
+
actions.push({ ref: "memory:_inference", mode: "memory-inference", result: memoryInference });
|
|
1882
|
+
info(`[improve] memory inference complete (${memoryInference.writtenFacts} facts written from ${memoryInference.splitParents} parents)`);
|
|
1883
|
+
}
|
|
1884
|
+
catch (err) {
|
|
1885
|
+
memoryInferenceDurationMs = Date.now() - inferenceStart;
|
|
1886
|
+
allWarnings.push(`memory inference failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
if (memoryInference && (memoryInference.splitParents > 0 || memoryInference.writtenFacts > 0)) {
|
|
1890
|
+
info("[improve] reindexing after memory inference writes");
|
|
1891
|
+
try {
|
|
1892
|
+
await reindexFn({ stashDir: primaryStashDir });
|
|
1893
|
+
reindexedAfterInference = true;
|
|
1894
|
+
info("[improve] reindex after memory inference complete");
|
|
1895
|
+
}
|
|
1896
|
+
catch (err) {
|
|
1897
|
+
allWarnings.push(`reindex after memory inference failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const graphEnabled = isProcessEnabled("index", "graph_extraction", config);
|
|
1901
|
+
const graphExtractionDisabledByProfile = improveProfile?.processes?.graphExtraction?.enabled === false;
|
|
1902
|
+
// Build the set of refs actually touched this run.
|
|
1903
|
+
const touchedRefs = new Set();
|
|
1904
|
+
for (const r of args.actionableRefs)
|
|
1905
|
+
touchedRefs.add(r.ref);
|
|
1906
|
+
for (const r of memoryRefsForInference)
|
|
1907
|
+
touchedRefs.add(r);
|
|
1908
|
+
// INVARIANT: graph extraction must never run on the full corpus from the
|
|
1909
|
+
// improve post-loop. Full-corpus scans belong in `akm index`. We enforce
|
|
1910
|
+
// this by ALWAYS passing `candidatePaths` (possibly an empty Set) to the
|
|
1911
|
+
// extractor — never `undefined`. With an empty Set, the extractor's
|
|
1912
|
+
// filter (graph-extraction.ts ~L452) rejects every file and returns the
|
|
1913
|
+
// empty result without scanning. The pass is still invoked so that the
|
|
1914
|
+
// action is recorded, the D9 post-consolidation reindex still fires, and
|
|
1915
|
+
// mock injection (graphExtractionFn) used by tests stays exercised.
|
|
1916
|
+
if (graphExtractionDisabledByProfile) {
|
|
1917
|
+
info("[improve] graph extraction skipped (disabled by improve profile)");
|
|
1918
|
+
}
|
|
1919
|
+
else if (sources.length > 0 && graphEnabled) {
|
|
1920
|
+
info("[improve] graph extraction starting");
|
|
1921
|
+
const extractionStart = Date.now();
|
|
1922
|
+
try {
|
|
1923
|
+
// D9: if consolidation ran but memory inference did not reindex, force a reindex
|
|
1924
|
+
// so graph extraction sees current DB state after consolidation writes.
|
|
1925
|
+
if (consolidationRan && !reindexedAfterInference) {
|
|
1926
|
+
info("[improve] reindexing after consolidation (graph extraction needs current state)");
|
|
1927
|
+
try {
|
|
1928
|
+
await reindexFn({ stashDir: primaryStashDir });
|
|
1929
|
+
reindexedAfterInference = true;
|
|
1930
|
+
info("[improve] reindex after consolidation complete");
|
|
1931
|
+
}
|
|
1932
|
+
catch (err) {
|
|
1933
|
+
allWarnings.push(`reindex after consolidation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
if (db && reindexedAfterInference) {
|
|
1937
|
+
closeDatabase(db);
|
|
1938
|
+
db = openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
|
|
1939
|
+
}
|
|
1940
|
+
// Resolve touched refs to absolute file paths. Empty Set is intentional
|
|
1941
|
+
// when no refs were touched — see INVARIANT above.
|
|
1942
|
+
const candidatePaths = new Set();
|
|
1943
|
+
if (primaryStashDir && touchedRefs.size > 0) {
|
|
1944
|
+
const writableDirSet = new Set(getWritableStashDirs(primaryStashDir).map((d) => path.resolve(d)));
|
|
1945
|
+
const resolved = await Promise.all([...touchedRefs].map((ref) => findAssetFilePath(ref, primaryStashDir, writableDirSet).catch(() => null)));
|
|
1946
|
+
for (const p of resolved) {
|
|
1947
|
+
if (typeof p === "string" && p.length > 0)
|
|
1948
|
+
candidatePaths.add(p);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
const progressHandler = (event) => {
|
|
1952
|
+
const current = event.currentPath ? ` ${path.basename(event.currentPath)}` : "";
|
|
1953
|
+
info(`[improve] graph extraction ${event.processed}/${event.total}${current} (extracted ${event.extracted}, entities ${event.totalEntities}, relations ${event.totalRelations})`);
|
|
1954
|
+
};
|
|
1955
|
+
// O-1 (#364): pass budget signal so a hung graph extraction call is cancelled.
|
|
1956
|
+
graphExtraction = await graphExtractionFn(config, sources, budgetSignal, db, false, progressHandler, {
|
|
1957
|
+
candidatePaths,
|
|
1958
|
+
});
|
|
1959
|
+
graphExtractionDurationMs = Date.now() - extractionStart;
|
|
1960
|
+
actions.push({ ref: "graph:_artifact", mode: "graph-extraction", result: graphExtraction });
|
|
1961
|
+
info(`[improve] graph extraction complete (${graphExtraction.quality.extractedFiles} files, ${graphExtraction.quality.entityCount} entities, ${graphExtraction.quality.relationCount} relations)`);
|
|
1962
|
+
}
|
|
1963
|
+
catch (err) {
|
|
1964
|
+
graphExtractionDurationMs = Date.now() - extractionStart;
|
|
1965
|
+
allWarnings.push(`graph extraction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
else if (sources.length > 0 && !graphEnabled) {
|
|
1969
|
+
info("[improve] graph extraction skipped (features.index.graph_extraction is disabled)");
|
|
1970
|
+
}
|
|
1971
|
+
// Orphan proposal purge — reject pending reflect proposals whose target
|
|
1972
|
+
// asset no longer exists on disk. Runs after graph extraction so newly
|
|
1973
|
+
// promoted assets from accept flows during this run are already present.
|
|
1974
|
+
if (primaryStashDir) {
|
|
1975
|
+
try {
|
|
1976
|
+
const purgeResult = purgeOrphanProposals(primaryStashDir, sources.map((s) => s.path));
|
|
1977
|
+
orphansPurged = purgeResult.rejected;
|
|
1978
|
+
if (purgeResult.rejected > 0) {
|
|
1979
|
+
info(`[improve] orphan purge: ${purgeResult.rejected}/${purgeResult.checked} orphaned proposals rejected (${purgeResult.durationMs}ms)`);
|
|
1980
|
+
}
|
|
1981
|
+
appendEvent({
|
|
1982
|
+
eventType: "proposal_orphan_purge",
|
|
1983
|
+
ref: "proposals:_orphan-purge",
|
|
1984
|
+
metadata: {
|
|
1985
|
+
checked: purgeResult.checked,
|
|
1986
|
+
rejected: purgeResult.rejected,
|
|
1987
|
+
durationMs: purgeResult.durationMs,
|
|
1988
|
+
byType: purgeResult.byType,
|
|
1989
|
+
orphans: purgeResult.orphans.map((o) => o.ref),
|
|
1990
|
+
},
|
|
1991
|
+
}, eventsCtx);
|
|
1992
|
+
}
|
|
1993
|
+
catch (err) {
|
|
1994
|
+
allWarnings.push(`orphan purge failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1995
|
+
}
|
|
1996
|
+
// Phase 6B (Advantage D6b): expire pending proposals that have aged past
|
|
1997
|
+
// the retention window. Runs AFTER orphan purge so we never double-archive
|
|
1998
|
+
// a proposal that orphan-purge already moved. `expireStaleProposals` emits
|
|
1999
|
+
// its own per-proposal `proposal_expired` events; we additionally emit a
|
|
2000
|
+
// single roll-up event here for parity with the orphan-purge surface.
|
|
2001
|
+
try {
|
|
2002
|
+
const expireResult = expireStaleProposals(primaryStashDir, config);
|
|
2003
|
+
proposalsExpired = expireResult.expired;
|
|
2004
|
+
if (expireResult.expired > 0) {
|
|
2005
|
+
info(`[improve] expiration: ${expireResult.expired}/${expireResult.checked} pending proposals expired ` +
|
|
2006
|
+
`(retention=${expireResult.retentionDays}d, ${expireResult.durationMs}ms)`);
|
|
2007
|
+
}
|
|
2008
|
+
appendEvent({
|
|
2009
|
+
eventType: "proposal_expiration_pass",
|
|
2010
|
+
ref: "proposals:_expiration",
|
|
2011
|
+
metadata: {
|
|
2012
|
+
checked: expireResult.checked,
|
|
2013
|
+
expired: expireResult.expired,
|
|
2014
|
+
durationMs: expireResult.durationMs,
|
|
2015
|
+
retentionDays: expireResult.retentionDays,
|
|
2016
|
+
expiredProposals: expireResult.expiredProposals,
|
|
2017
|
+
},
|
|
2018
|
+
}, eventsCtx);
|
|
2019
|
+
}
|
|
2020
|
+
catch (err) {
|
|
2021
|
+
allWarnings.push(`proposal expiration failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
// Fix #2 (observability 0.8.0): trim the events table in state.db so it
|
|
2025
|
+
// doesn't grow unbounded. `akm health` writes a `health_probe` row on every
|
|
2026
|
+
// invocation, and every command surface emits at least one event besides —
|
|
2027
|
+
// without this trim, state.db is a permanent append-only log. Config key
|
|
2028
|
+
// `improve.eventRetentionDays` (default 90, set 0 to disable) controls the
|
|
2029
|
+
// window. `purgeOldEvents()` opens its own state.db handle separate from
|
|
2030
|
+
// the index `db` above (different SQLite file).
|
|
2031
|
+
{
|
|
2032
|
+
const retentionDays = typeof config.improve?.eventRetentionDays === "number" ? config.improve.eventRetentionDays : 90;
|
|
2033
|
+
if (retentionDays > 0) {
|
|
2034
|
+
let stateDb;
|
|
2035
|
+
try {
|
|
2036
|
+
stateDb = openStateDatabase();
|
|
2037
|
+
const purgedCount = purgeOldEvents(stateDb, retentionDays);
|
|
2038
|
+
if (purgedCount > 0) {
|
|
2039
|
+
info(`[improve] events purge: ${purgedCount} event(s) older than ${retentionDays}d removed from state.db`);
|
|
2040
|
+
}
|
|
2041
|
+
appendEvent({
|
|
2042
|
+
eventType: "events_purged",
|
|
2043
|
+
ref: "events:_purge",
|
|
2044
|
+
metadata: { purgedCount, retentionDays },
|
|
2045
|
+
}, eventsCtx);
|
|
2046
|
+
// improve_runs uses the same retention window as events — both are
|
|
2047
|
+
// observability/audit data, both grow append-only, both have a
|
|
2048
|
+
// dedicated purge helper. Mirroring the events purge here means a
|
|
2049
|
+
// single retention knob (improve.eventRetentionDays) governs both.
|
|
2050
|
+
const improveRunsPurged = purgeOldImproveRuns(stateDb, retentionDays);
|
|
2051
|
+
if (improveRunsPurged > 0) {
|
|
2052
|
+
info(`[improve] improve_runs purge: ${improveRunsPurged} run(s) older than ${retentionDays}d removed from state.db`);
|
|
2053
|
+
}
|
|
2054
|
+
appendEvent({
|
|
2055
|
+
eventType: "improve_runs_purged",
|
|
2056
|
+
ref: "improve_runs:_purge",
|
|
2057
|
+
metadata: { purgedCount: improveRunsPurged, retentionDays },
|
|
2058
|
+
}, eventsCtx);
|
|
2059
|
+
}
|
|
2060
|
+
catch (err) {
|
|
2061
|
+
allWarnings.push(`events purge failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2062
|
+
}
|
|
2063
|
+
finally {
|
|
2064
|
+
if (stateDb) {
|
|
2065
|
+
try {
|
|
2066
|
+
stateDb.close();
|
|
2067
|
+
}
|
|
2068
|
+
catch {
|
|
2069
|
+
// best-effort
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
// Phase 4A (staleness detection). Activates the `deprecated` belief-state
|
|
2076
|
+
// machinery shipped in Phase 1A. Default OFF — gated by
|
|
2077
|
+
// `features.index.staleness_detection.enabled`. Runs after orphan purge
|
|
2078
|
+
// and before the URL check (which lives in the outer caller).
|
|
2079
|
+
if (sources.length > 0) {
|
|
2080
|
+
try {
|
|
2081
|
+
stalenessDetection = await stalenessDetectionFn(config, sources, budgetSignal, db);
|
|
2082
|
+
if (stalenessDetection.considered > 0) {
|
|
2083
|
+
info(`[improve] staleness detection complete (considered ${stalenessDetection.considered}, ` +
|
|
2084
|
+
`deprecated ${stalenessDetection.deprecated}, confirmed ${stalenessDetection.confirmed}, ` +
|
|
2085
|
+
`skipped ${stalenessDetection.skipped}, ${stalenessDetection.durationMs}ms)`);
|
|
2086
|
+
}
|
|
2087
|
+
for (const w of stalenessDetection.warnings)
|
|
2088
|
+
allWarnings.push(`[improve] staleness detection: ${w}`);
|
|
2089
|
+
}
|
|
2090
|
+
catch (err) {
|
|
2091
|
+
allWarnings.push(`staleness detection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
finally {
|
|
2096
|
+
if (db)
|
|
2097
|
+
closeDatabase(db);
|
|
2098
|
+
}
|
|
2099
|
+
return {
|
|
2100
|
+
...(memoryInference ? { memoryInference } : {}),
|
|
2101
|
+
...(graphExtraction ? { graphExtraction } : {}),
|
|
2102
|
+
...(stalenessDetection ? { stalenessDetection } : {}),
|
|
2103
|
+
...(actions.length > 0 ? { actions } : {}),
|
|
2104
|
+
memoryInferenceDurationMs,
|
|
2105
|
+
graphExtractionDurationMs,
|
|
2106
|
+
orphansPurged,
|
|
2107
|
+
proposalsExpired,
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
function shouldAnalyzeMemoryCleanup(scope, eligibleMemories, primaryStashDir) {
|
|
2111
|
+
if (!primaryStashDir || eligibleMemories === 0)
|
|
2112
|
+
return false;
|
|
2113
|
+
if (scope.mode === "all")
|
|
2114
|
+
return true;
|
|
2115
|
+
if (scope.mode === "type")
|
|
2116
|
+
return scope.value === "memory";
|
|
2117
|
+
if (!scope.value)
|
|
2118
|
+
return false;
|
|
2119
|
+
return parseAssetRef(scope.value).type === "memory";
|
|
2120
|
+
}
|
|
2121
|
+
function shapeMemoryCleanup(plan) {
|
|
2122
|
+
return {
|
|
2123
|
+
analyzedDerived: plan.analyzedDerived,
|
|
2124
|
+
pruneCandidates: plan.pruneCandidates,
|
|
2125
|
+
contradictionCandidates: plan.contradictionCandidates,
|
|
2126
|
+
beliefStateTransitions: plan.beliefStateTransitions,
|
|
2127
|
+
consolidationCandidates: plan.consolidationCandidates,
|
|
2128
|
+
...(plan.relativeDateCandidates.length > 0 ? { relativeDateCandidates: plan.relativeDateCandidates } : {}),
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
function buildUtilityMap(refs) {
|
|
2132
|
+
const map = new Map();
|
|
2133
|
+
if (refs.length === 0)
|
|
2134
|
+
return map;
|
|
2135
|
+
const refSet = new Set(refs.map((r) => r.ref));
|
|
2136
|
+
let db;
|
|
2137
|
+
try {
|
|
2138
|
+
db = openExistingDatabase();
|
|
2139
|
+
const allDbEntries = getAllEntries(db);
|
|
2140
|
+
const idToRef = new Map();
|
|
2141
|
+
for (const indexed of allDbEntries) {
|
|
2142
|
+
const ref = makeAssetRef(indexed.entry.type, indexed.entry.name);
|
|
2143
|
+
if (refSet.has(ref))
|
|
2144
|
+
idToRef.set(indexed.id, ref);
|
|
2145
|
+
}
|
|
2146
|
+
const ids = [...idToRef.keys()];
|
|
2147
|
+
if (ids.length > 0) {
|
|
2148
|
+
const { global: scores } = getUtilityScoresByIds(db, ids);
|
|
2149
|
+
for (const [id, score] of scores) {
|
|
2150
|
+
const ref = idToRef.get(id);
|
|
2151
|
+
if (ref)
|
|
2152
|
+
map.set(ref, score.utility);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
catch (err) {
|
|
2157
|
+
rethrowIfTestIsolationError(err);
|
|
2158
|
+
// best-effort: if DB unavailable, all utilities default to 0
|
|
2159
|
+
}
|
|
2160
|
+
finally {
|
|
2161
|
+
if (db)
|
|
2162
|
+
closeDatabase(db);
|
|
2163
|
+
}
|
|
2164
|
+
return map;
|
|
2165
|
+
}
|
|
2166
|
+
async function findAssetFilePath(ref, stashDir, writableDirSet) {
|
|
2167
|
+
return resolveAssetPath(ref, {
|
|
2168
|
+
stashDir,
|
|
2169
|
+
mode: "disk-only",
|
|
2170
|
+
writableDirSet,
|
|
2171
|
+
directoryIndexNames: ["SKILL.md"],
|
|
2172
|
+
preserveDirectNameFallback: true,
|
|
2173
|
+
honorOrigin: false,
|
|
2174
|
+
});
|
|
2175
|
+
}
|