akm-cli 0.9.0-beta.11 → 0.9.0-beta.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/dist/assets/prompts/consolidate-system.md +23 -0
- package/dist/assets/prompts/contradiction-judge.md +33 -0
- package/dist/assets/prompts/distill-knowledge-system.md +22 -0
- package/dist/assets/prompts/distill-lesson-system.md +36 -0
- package/dist/assets/prompts/extract-session.md +5 -1
- package/dist/assets/prompts/graph-extract-system.md +1 -0
- package/dist/assets/prompts/memory-infer-system.md +1 -0
- package/dist/assets/prompts/memory-infer-user.md +5 -0
- package/dist/assets/prompts/metadata-enhance-system.md +1 -0
- package/dist/assets/prompts/procedural-system.md +44 -0
- package/dist/assets/prompts/recombine-system.md +40 -0
- package/dist/assets/prompts/staleness-detect-system.md +6 -0
- package/dist/assets/prompts/validate-summary-judge.md +1 -0
- package/dist/assets/templates/html/health.html +25 -27
- package/dist/cli.js +2 -2
- package/dist/commands/agent/contribute-cli.js +16 -3
- package/dist/commands/feedback-cli.js +48 -44
- package/dist/commands/health/html-report.js +140 -16
- package/dist/commands/health.js +277 -1
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate.js +595 -105
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +119 -64
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +115 -1
- package/dist/commands/improve/extract-prompt.js +32 -1
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +210 -30
- package/dist/commands/improve/feedback-valence.js +54 -0
- package/dist/commands/improve/homeostatic.js +467 -0
- package/dist/commands/improve/improve-auto-accept.js +80 -7
- package/dist/commands/improve/improve-profiles.js +8 -0
- package/dist/commands/improve/improve.js +991 -61
- package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
- package/dist/commands/improve/outcome-loop.js +256 -0
- package/dist/commands/improve/proactive-maintenance.js +9 -35
- package/dist/commands/improve/procedural.js +409 -0
- package/dist/commands/improve/recombine.js +488 -0
- package/dist/commands/improve/reflect.js +20 -1
- package/dist/commands/improve/related-sessions.js +120 -0
- package/dist/commands/improve/salience.js +386 -0
- package/dist/commands/improve/triage.js +95 -0
- package/dist/commands/lint/agent-linter.js +19 -24
- package/dist/commands/lint/base-linter.js +173 -60
- package/dist/commands/lint/command-linter.js +19 -24
- package/dist/commands/lint/env-key-rules.js +34 -1
- package/dist/commands/lint/index.js +30 -13
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +5 -2
- package/dist/commands/lint/task-linter.js +3 -3
- package/dist/commands/lint/workflow-linter.js +26 -1
- package/dist/commands/proposal/validators/proposals.js +4 -0
- package/dist/commands/read/curate.js +284 -86
- package/dist/commands/read/search-cli.js +7 -0
- package/dist/commands/read/search.js +1 -0
- package/dist/commands/sources/installed-stashes.js +5 -1
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/config/config-schema.js +211 -3
- package/dist/core/config/config.js +2 -2
- package/dist/core/logs-db.js +4 -3
- package/dist/core/state-db.js +555 -29
- package/dist/indexer/db/db.js +250 -27
- package/dist/indexer/db/graph-db.js +81 -86
- package/dist/indexer/graph/graph-boost.js +51 -41
- package/dist/indexer/passes/memory-inference.js +10 -3
- package/dist/indexer/passes/staleness-detect.js +2 -5
- package/dist/indexer/search/db-search.js +15 -4
- package/dist/indexer/search/ranking.js +4 -0
- package/dist/integrations/harnesses/claude/session-log.js +10 -0
- package/dist/integrations/harnesses/opencode/session-log.js +9 -0
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/embedder.js +27 -3
- package/dist/llm/embedders/local.js +66 -2
- package/dist/llm/graph-extract.js +2 -1
- package/dist/llm/memory-infer.js +4 -8
- package/dist/llm/metadata-enhance.js +9 -1
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/text/helpers.js +9 -0
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +1025 -567
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +435 -269
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/data-and-telemetry.md +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,140 @@
|
|
|
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 { akmExtract } from "./extract.js";
|
|
5
|
+
const DEFAULT_DEBOUNCE_MS = 2000;
|
|
6
|
+
/** Small look-back window for the default trigger's `akmExtract` call. */
|
|
7
|
+
const DEFAULT_TRIGGER_SINCE = "10m";
|
|
8
|
+
/**
|
|
9
|
+
* Does `filePath` look like a real session file under one of the configured
|
|
10
|
+
* roots? True only when the path is under a root AND matches that harness's
|
|
11
|
+
* on-disk session-file shape:
|
|
12
|
+
* - claude-code: `<root>/<project>/<id>.jsonl`
|
|
13
|
+
* - opencode: `<root>/<project>/<id>.json`
|
|
14
|
+
* Anything out-of-root, or in-root with the wrong extension, returns false.
|
|
15
|
+
*/
|
|
16
|
+
export function isSessionFile(filePath, roots) {
|
|
17
|
+
return matchHarness(filePath, roots) !== undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Normalize a path for prefix comparison (ensure a trailing separator). */
|
|
20
|
+
function withSep(dir) {
|
|
21
|
+
return dir.endsWith("/") ? dir : `${dir}/`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve which harness a path belongs to by longest-prefix match against the
|
|
25
|
+
* configured roots, then validate the file shape for that harness. Returns the
|
|
26
|
+
* harness name, or `undefined` when the path is out-of-root or not a session
|
|
27
|
+
* file. Longest-prefix match guards against the (in practice impossible) case
|
|
28
|
+
* of nested roots.
|
|
29
|
+
*/
|
|
30
|
+
export function matchHarness(filePath, roots) {
|
|
31
|
+
let best;
|
|
32
|
+
let bestLen = -1;
|
|
33
|
+
for (const target of roots) {
|
|
34
|
+
for (const root of target.roots) {
|
|
35
|
+
const prefix = withSep(root);
|
|
36
|
+
if (filePath.startsWith(prefix) && prefix.length > bestLen) {
|
|
37
|
+
best = { harnessName: target.harnessName };
|
|
38
|
+
bestLen = prefix.length;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!best)
|
|
43
|
+
return undefined;
|
|
44
|
+
// Validate the file shape for the resolved harness.
|
|
45
|
+
if (best.harnessName === "claude-code") {
|
|
46
|
+
return filePath.endsWith(".jsonl") ? best.harnessName : undefined;
|
|
47
|
+
}
|
|
48
|
+
if (best.harnessName === "opencode") {
|
|
49
|
+
return filePath.endsWith(".json") ? best.harnessName : undefined;
|
|
50
|
+
}
|
|
51
|
+
// Unknown harness: accept any file under its root.
|
|
52
|
+
return best.harnessName;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Start the watch-mode core. Returns a handle whose `stop()` tears everything
|
|
56
|
+
* down. The event source is injected, so this never touches the real
|
|
57
|
+
* filesystem itself.
|
|
58
|
+
*/
|
|
59
|
+
export function akmExtractWatch(options) {
|
|
60
|
+
const { roots, eventSource, debounceMs = DEFAULT_DEBOUNCE_MS, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, isSessionFile: isSessionFileFn = isSessionFile, } = options;
|
|
61
|
+
const onTrigger = options.onTrigger ??
|
|
62
|
+
(async (harnessName) => {
|
|
63
|
+
await akmExtract({ type: harnessName, since: DEFAULT_TRIGGER_SINCE, force: false });
|
|
64
|
+
});
|
|
65
|
+
let stopped = false;
|
|
66
|
+
// Pending debounce timers, keyed by harness name.
|
|
67
|
+
const pendingTimers = new Map();
|
|
68
|
+
// Harnesses with an in-flight trigger, and whether a re-run is queued.
|
|
69
|
+
const inFlight = new Set();
|
|
70
|
+
const rerunPending = new Set();
|
|
71
|
+
function runTrigger(harnessName) {
|
|
72
|
+
if (stopped)
|
|
73
|
+
return;
|
|
74
|
+
if (inFlight.has(harnessName)) {
|
|
75
|
+
// A trigger is already running for this harness; coalesce into a single
|
|
76
|
+
// re-run after it resolves rather than launching a concurrent extract.
|
|
77
|
+
rerunPending.add(harnessName);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
inFlight.add(harnessName);
|
|
81
|
+
// Invoke synchronously so a synchronous `onTrigger` observes its effect
|
|
82
|
+
// before control returns (tests advance the clock then assert inline).
|
|
83
|
+
// Wrap the (possibly async) result in a promise to drive the in-flight
|
|
84
|
+
// guard's release without forcing the trigger itself into a microtask.
|
|
85
|
+
let result;
|
|
86
|
+
try {
|
|
87
|
+
result = onTrigger(harnessName);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// One failed extract must never kill the watcher (mirrors the non-fatal
|
|
91
|
+
// handling in collectSessionEvents).
|
|
92
|
+
console.error(`[akm] extract --watch trigger for "${harnessName}" failed:`, err);
|
|
93
|
+
inFlight.delete(harnessName);
|
|
94
|
+
if (rerunPending.delete(harnessName) && !stopped)
|
|
95
|
+
runTrigger(harnessName);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
Promise.resolve(result)
|
|
99
|
+
.catch((err) => {
|
|
100
|
+
console.error(`[akm] extract --watch trigger for "${harnessName}" failed:`, err);
|
|
101
|
+
})
|
|
102
|
+
.finally(() => {
|
|
103
|
+
inFlight.delete(harnessName);
|
|
104
|
+
if (rerunPending.delete(harnessName) && !stopped) {
|
|
105
|
+
runTrigger(harnessName);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function onEvent(e) {
|
|
110
|
+
if (stopped)
|
|
111
|
+
return;
|
|
112
|
+
if (!isSessionFileFn(e.path, roots))
|
|
113
|
+
return;
|
|
114
|
+
const harnessName = matchHarness(e.path, roots);
|
|
115
|
+
if (!harnessName)
|
|
116
|
+
return;
|
|
117
|
+
const existing = pendingTimers.get(harnessName);
|
|
118
|
+
if (existing !== undefined)
|
|
119
|
+
clearTimeoutFn(existing);
|
|
120
|
+
const timer = setTimeoutFn(() => {
|
|
121
|
+
pendingTimers.delete(harnessName);
|
|
122
|
+
if (!stopped)
|
|
123
|
+
runTrigger(harnessName);
|
|
124
|
+
}, debounceMs);
|
|
125
|
+
pendingTimers.set(harnessName, timer);
|
|
126
|
+
}
|
|
127
|
+
const unsubscribe = eventSource.subscribe(onEvent);
|
|
128
|
+
return {
|
|
129
|
+
stop() {
|
|
130
|
+
if (stopped)
|
|
131
|
+
return;
|
|
132
|
+
stopped = true;
|
|
133
|
+
unsubscribe();
|
|
134
|
+
for (const timer of pendingTimers.values())
|
|
135
|
+
clearTimeoutFn(timer);
|
|
136
|
+
pendingTimers.clear();
|
|
137
|
+
rerunPending.clear();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -36,11 +36,15 @@ import { normalizeHarnessId } from "../../integrations/harnesses/index.js";
|
|
|
36
36
|
import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
|
|
37
37
|
import { preFilterSession } from "../../integrations/session-logs/pre-filter.js";
|
|
38
38
|
import { chatCompletion } from "../../llm/client.js";
|
|
39
|
+
import { embed } from "../../llm/embedder.js";
|
|
39
40
|
import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
|
|
41
|
+
import { sha256Hex } from "../../runtime.js";
|
|
40
42
|
import { createProposal, isProposalSkipped } from "../proposal/validators/proposals.js";
|
|
41
43
|
import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload } from "./extract-prompt.js";
|
|
44
|
+
import { applySchemaSimilarityPenalty, buildHotProbationFrontmatter, loadDerivedLayerEmbeddings, } from "./homeostatic.js";
|
|
42
45
|
import { resolveProcessEnabled } from "./improve-profiles.js";
|
|
43
46
|
import { buildSessionSummaryPrompt, parseSessionSummary, SESSION_SUMMARY_JSON_SCHEMA, sessionMeetsDurationGate, writeSessionAsset, } from "./session-asset.js";
|
|
47
|
+
import { resolveTriageConfig, scoreSessionTriage } from "./triage.js";
|
|
44
48
|
/** Default minimum session duration (minutes) for session indexing (#561). */
|
|
45
49
|
const DEFAULT_MIN_SESSION_DURATION_MINUTES = 5;
|
|
46
50
|
/**
|
|
@@ -121,9 +125,43 @@ function buildCandidateProposal(candidate, sourceRef) {
|
|
|
121
125
|
if (candidate.type === "lesson" && candidate.when_to_use) {
|
|
122
126
|
fm.when_to_use = candidate.when_to_use;
|
|
123
127
|
}
|
|
128
|
+
// #615 WS-0: preserve ordered-action + outcome data in frontmatter so the data
|
|
129
|
+
// survives even if source transcripts are not re-extractable later. The
|
|
130
|
+
// procedural-compilation feature (detection/compilation) is deferred to 0.10+.
|
|
131
|
+
if (candidate.orderedActions && candidate.orderedActions.length > 0) {
|
|
132
|
+
fm.orderedActions = candidate.orderedActions;
|
|
133
|
+
if (candidate.outcomeData) {
|
|
134
|
+
fm.outcomeData = candidate.outcomeData;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
124
137
|
const content = assembleAsset(fm, candidate.body);
|
|
125
138
|
return { ref, content, description };
|
|
126
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Canonicalize a session's content into a single deterministic string for
|
|
142
|
+
* hashing (#602). Each event is rendered `<role>\n<text>` and events are joined
|
|
143
|
+
* with a NUL-delimited separator (`\n\0\n`) so event boundaries cannot be forged
|
|
144
|
+
* by text that itself contains newlines.
|
|
145
|
+
*
|
|
146
|
+
* The input is the RAW `data.events` stream — NOT the pre-filtered / truncated
|
|
147
|
+
* set — so the hash is stable across `maxTotalChars` (and any other pre-filter)
|
|
148
|
+
* config changes: changing config must NEVER change the hash (idempotency AC).
|
|
149
|
+
* `inlineRefs` and ref metadata (title, startedAt/endedAt timestamps) are
|
|
150
|
+
* deliberately EXCLUDED so clock/title churn (and an agent adding an inline
|
|
151
|
+
* `akm remember` mid-session) does not change the hash.
|
|
152
|
+
*/
|
|
153
|
+
function canonicalizeSessionContent(data) {
|
|
154
|
+
return data.events.map((e) => `${e.role ?? "unknown"}\n${e.text}`).join("\n\0\n");
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* sha256 (hex) of the normalized session content (#602). This is the byte-exact,
|
|
158
|
+
* clock-independent skip authority that replaced the old `session_ended_at`
|
|
159
|
+
* timestamp comparison. See {@link canonicalizeSessionContent} for exactly what
|
|
160
|
+
* is (and is not) hashed.
|
|
161
|
+
*/
|
|
162
|
+
export function hashSessionContent(data) {
|
|
163
|
+
return sha256Hex(canonicalizeSessionContent(data));
|
|
164
|
+
}
|
|
127
165
|
/**
|
|
128
166
|
* Process one session through the full pipeline: read → pre-filter → LLM →
|
|
129
167
|
* parse → createProposal-per-candidate. Returns the per-session result.
|
|
@@ -132,7 +170,16 @@ function buildCandidateProposal(candidate, sourceRef) {
|
|
|
132
170
|
* proposal validation failure) the session result records a warning and
|
|
133
171
|
* keeps going — one session's bad luck never aborts a multi-session run.
|
|
134
172
|
*/
|
|
135
|
-
async function processSession(harness, sessionRef, stashDir, config, llmConfig, chat, ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars,
|
|
173
|
+
async function processSession(harness, sessionRef, stashDir, config, llmConfig, chat, ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars,
|
|
174
|
+
// #626 — pre-LLM heuristic triage gate. Default-off (enabled:false) takes the
|
|
175
|
+
// exact pre-change path (no scorer call, no new skipReason).
|
|
176
|
+
triage, sessionIndexing, schemaSimilarityCtx,
|
|
177
|
+
// #602 — already-extracted skip moved INSIDE processSession: the content hash
|
|
178
|
+
// can only be computed after readSession, so the skip decision lives here. The
|
|
179
|
+
// prior row + bypass flags are threaded in from the caller. Skipping here still
|
|
180
|
+
// costs ZERO LLM calls (the expensive resource #602 protects); only the cheap
|
|
181
|
+
// file read is incurred.
|
|
182
|
+
prior, force, singleSession) {
|
|
136
183
|
const warnings = [];
|
|
137
184
|
let data;
|
|
138
185
|
try {
|
|
@@ -150,6 +197,25 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
|
|
|
150
197
|
skipReason: "read_failed",
|
|
151
198
|
};
|
|
152
199
|
}
|
|
200
|
+
// #602 — content-hash skip. Computed on the RAW event stream immediately after
|
|
201
|
+
// a successful read, BEFORE the pre-filter / minContentChars / triage gates, so
|
|
202
|
+
// an unchanged session never reaches the LLM. Hash-based ⇒ clock-independent
|
|
203
|
+
// (immune to the Jun 11-12 timestamp double-extract/over-throttle bug). --force
|
|
204
|
+
// and single-session (explicit sessionId) modes bypass the skip entirely.
|
|
205
|
+
const contentHash = hashSessionContent(data);
|
|
206
|
+
if (!force && !singleSession && shouldSkipAlreadyExtractedSession(prior, contentHash)) {
|
|
207
|
+
return {
|
|
208
|
+
sessionId: sessionRef.sessionId,
|
|
209
|
+
harness: harness.name,
|
|
210
|
+
candidateCount: 0,
|
|
211
|
+
proposalIds: [],
|
|
212
|
+
preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
|
|
213
|
+
warnings: [`already extracted (content unchanged) at ${prior?.processed_at}; pass --force to re-process`],
|
|
214
|
+
skipped: true,
|
|
215
|
+
skipReason: "already_extracted",
|
|
216
|
+
contentHash,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
153
219
|
const filtered = preFilterSession(data, {
|
|
154
220
|
...(typeof maxTotalChars === "number" ? { maxTotalChars } : {}),
|
|
155
221
|
});
|
|
@@ -176,8 +242,34 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
|
|
|
176
242
|
warnings: [],
|
|
177
243
|
skipped: true,
|
|
178
244
|
skipReason: "too_short",
|
|
245
|
+
contentHash,
|
|
179
246
|
};
|
|
180
247
|
}
|
|
248
|
+
// #626 — pre-LLM heuristic triage gate. Runs AFTER minContentChars + the
|
|
249
|
+
// already-extracted skip check (both in the caller / above), BEFORE the
|
|
250
|
+
// extraction prompt and the session-asset write. When the session scores below
|
|
251
|
+
// the configured threshold we triage it out: no chat() call, no session asset,
|
|
252
|
+
// no proposals. Pure-heuristic — zero added LLM cost. Default-off → skipped.
|
|
253
|
+
if (triage.enabled) {
|
|
254
|
+
const t = scoreSessionTriage(data, triage.minScore);
|
|
255
|
+
if (!t.pass) {
|
|
256
|
+
return {
|
|
257
|
+
sessionId: sessionRef.sessionId,
|
|
258
|
+
harness: harness.name,
|
|
259
|
+
candidateCount: 0,
|
|
260
|
+
proposalIds: [],
|
|
261
|
+
preFilter: {
|
|
262
|
+
inputCount: filtered.stats.inputCount,
|
|
263
|
+
outputCount: filtered.stats.outputCount,
|
|
264
|
+
truncatedCount: filtered.stats.truncatedCount,
|
|
265
|
+
},
|
|
266
|
+
warnings: [],
|
|
267
|
+
skipped: true,
|
|
268
|
+
skipReason: "triaged_out",
|
|
269
|
+
contentHash,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
181
273
|
const prompt = buildExtractPrompt({ data, events: filtered.events, inlineRefs: data.inlineRefs });
|
|
182
274
|
// #561 — ADDITIVE session indexing. Generate + write the session asset
|
|
183
275
|
// (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only records a
|
|
@@ -226,6 +318,7 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
|
|
|
226
318
|
warnings: ["session_extraction feature returned empty (disabled / timeout / error)"],
|
|
227
319
|
skipped: true,
|
|
228
320
|
skipReason: "llm_unavailable",
|
|
321
|
+
contentHash,
|
|
229
322
|
};
|
|
230
323
|
}
|
|
231
324
|
const payload = parseExtractPayload(llmRaw);
|
|
@@ -257,15 +350,34 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
|
|
|
257
350
|
truncatedCount: filtered.stats.truncatedCount,
|
|
258
351
|
},
|
|
259
352
|
warnings,
|
|
353
|
+
contentHash,
|
|
260
354
|
...sessionAsset,
|
|
261
355
|
};
|
|
262
356
|
}
|
|
357
|
+
// WS-3b step 0c: hot-probation intake buffer (#604).
|
|
358
|
+
// When enabled, system-generated extractions enter captureMode: hot-probation
|
|
359
|
+
// so they spend ONE consolidation cycle in probation before the deterministic
|
|
360
|
+
// dedup+quality pass promotes them. Default OFF.
|
|
361
|
+
const hotProbationEnabled = config.profiles?.improve?.default?.processes?.extract?.hotProbation
|
|
362
|
+
?.enabled === true;
|
|
263
363
|
for (const candidate of payload.candidates) {
|
|
264
364
|
if (dryRun) {
|
|
265
365
|
proposalIds.push(`dry-run:${candidate.type}:${candidate.name}`);
|
|
266
366
|
continue;
|
|
267
367
|
}
|
|
268
368
|
try {
|
|
369
|
+
// WS-3b Step-0b: schema-similarity intake gate. When enabled and the
|
|
370
|
+
// candidate is a lesson/knowledge whose body embedding is within ε of an
|
|
371
|
+
// existing derived-layer node, down-prioritize by multiplying confidence by
|
|
372
|
+
// the penalty. PARITY: schemaSimilarityCtx is null when the flag is off →
|
|
373
|
+
// applySchemaSimilarityPenalty returns the original confidence untouched and
|
|
374
|
+
// never embeds. (Logic lives in homeostatic.ts so it is unit-testable.)
|
|
375
|
+
const gateResult = await applySchemaSimilarityPenalty(candidate, schemaSimilarityCtx, (text) => schemaSimilarityCtx?.embedFn
|
|
376
|
+
? schemaSimilarityCtx.embedFn(text)
|
|
377
|
+
: embed(text, schemaSimilarityCtx?.embeddingConfig));
|
|
378
|
+
const effectiveConfidence = gateResult.effectiveConfidence;
|
|
379
|
+
if (gateResult.warning)
|
|
380
|
+
warn(gateResult.warning);
|
|
269
381
|
const { ref, content, description } = buildCandidateProposal(candidate, sessionRef);
|
|
270
382
|
const result = createProposal(stashDir, {
|
|
271
383
|
ref,
|
|
@@ -276,9 +388,22 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
|
|
|
276
388
|
frontmatter: {
|
|
277
389
|
description,
|
|
278
390
|
...(candidate.when_to_use ? { when_to_use: candidate.when_to_use } : {}),
|
|
279
|
-
...(
|
|
391
|
+
...(effectiveConfidence !== undefined ? { confidence: effectiveConfidence } : {}),
|
|
280
392
|
sources: [`session:${sessionRef.harness}:${sessionRef.sessionId}`],
|
|
281
393
|
evidence: candidate.evidence,
|
|
394
|
+
// #615 WS-0: mirror ordered-action + outcome data in the proposal
|
|
395
|
+
// frontmatter record so downstream tooling can read it without
|
|
396
|
+
// re-parsing the content body. Omitted when not present.
|
|
397
|
+
...(candidate.orderedActions && candidate.orderedActions.length > 0
|
|
398
|
+
? { orderedActions: candidate.orderedActions }
|
|
399
|
+
: {}),
|
|
400
|
+
...(candidate.outcomeData ? { outcomeData: candidate.outcomeData } : {}),
|
|
401
|
+
// WS-3b step 0c: tag system-generated extractions as hot-probation
|
|
402
|
+
// when the feature is enabled. The consolidation pass will exclude
|
|
403
|
+
// them from the LLM merge pool until the intake dedup+quality pass
|
|
404
|
+
// runs against them. User-explicit `akm remember` (captureMode: hot)
|
|
405
|
+
// is unaffected — this only applies to extract-generated proposals.
|
|
406
|
+
...(hotProbationEnabled ? buildHotProbationFrontmatter() : {}),
|
|
282
407
|
},
|
|
283
408
|
},
|
|
284
409
|
}, ctx);
|
|
@@ -319,6 +444,7 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
|
|
|
319
444
|
truncatedCount: filtered.stats.truncatedCount,
|
|
320
445
|
},
|
|
321
446
|
warnings,
|
|
447
|
+
contentHash,
|
|
322
448
|
...sessionAsset,
|
|
323
449
|
};
|
|
324
450
|
}
|
|
@@ -399,6 +525,9 @@ export async function akmExtract(options) {
|
|
|
399
525
|
: DEFAULT_MAX_SESSIONS_PER_RUN;
|
|
400
526
|
// Default discovery window — process config can override the built-in 24h.
|
|
401
527
|
const effectiveSince = options.since ?? extractProcess?.defaultSince;
|
|
528
|
+
// #626 — resolve the triage gate config once per run. Default-off → the
|
|
529
|
+
// per-session path never calls the scorer and emits no telemetry.
|
|
530
|
+
const triage = resolveTriageConfig(extractProcess);
|
|
402
531
|
// #561 — resolve session-indexing config. Default ON: we only reach this code
|
|
403
532
|
// when `session_extraction` is enabled AND an LLM is configured (both checked
|
|
404
533
|
// above), so defaulting on costs nothing offline (the summary call fails open)
|
|
@@ -496,6 +625,10 @@ export async function akmExtract(options) {
|
|
|
496
625
|
const sessions = [];
|
|
497
626
|
let processedCount = 0;
|
|
498
627
|
let skippedCount = 0;
|
|
628
|
+
// #626 — per-run triage aggregation counters (counts-only telemetry, AC4).
|
|
629
|
+
let triageEvaluated = 0;
|
|
630
|
+
let triagePassed = 0;
|
|
631
|
+
let triagedOut = 0;
|
|
499
632
|
const allProposalIds = [];
|
|
500
633
|
const topLevelWarnings = [];
|
|
501
634
|
const chat = options.chat ?? chatCompletion;
|
|
@@ -519,27 +652,28 @@ export async function akmExtract(options) {
|
|
|
519
652
|
stateDb = undefined;
|
|
520
653
|
}
|
|
521
654
|
}
|
|
655
|
+
// WS-3b Step-0b: schema-similarity intake gate.
|
|
656
|
+
// Load derived-layer (lesson/knowledge) embeddings once per run, but ONLY
|
|
657
|
+
// when the gate is enabled in config. When disabled (the default) this block
|
|
658
|
+
// is fully skipped and schemaSimilarityCtx stays null → byte-identical to
|
|
659
|
+
// prior behaviour.
|
|
660
|
+
const schemaSimilarityCfg = extractProcess?.schemaSimilarity;
|
|
661
|
+
let schemaSimilarityCtx = null;
|
|
662
|
+
if (schemaSimilarityCfg?.enabled === true) {
|
|
663
|
+
const derivedEmbeddings = options.schemaSimilarityEmbeddings ?? loadDerivedLayerEmbeddings();
|
|
664
|
+
schemaSimilarityCtx = {
|
|
665
|
+
config: schemaSimilarityCfg,
|
|
666
|
+
derivedEmbeddings,
|
|
667
|
+
embeddingConfig: config.embedding,
|
|
668
|
+
embedFn: options.schemaSimilarityEmbedFn,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
522
671
|
for (const summary of candidates) {
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
672
|
+
// #602 — the already-extracted skip moved INTO processSession (the content
|
|
673
|
+
// hash needs the session body, only available after readSession). The prior
|
|
674
|
+
// row + bypass flags are threaded through; an unchanged session returns
|
|
675
|
+
// skipReason 'already_extracted' WITHOUT any LLM call.
|
|
526
676
|
const prior = seenMap.get(summary.sessionId);
|
|
527
|
-
if (!options.force && !options.sessionId && shouldSkipAlreadyExtractedSession(prior, summary.endedAt)) {
|
|
528
|
-
sessions.push({
|
|
529
|
-
sessionId: summary.sessionId,
|
|
530
|
-
harness: harness.name,
|
|
531
|
-
candidateCount: 0,
|
|
532
|
-
proposalIds: [],
|
|
533
|
-
preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
|
|
534
|
-
warnings: [
|
|
535
|
-
`already extracted at ${prior?.processed_at}; pass --force to re-process or wait until the session has new content`,
|
|
536
|
-
],
|
|
537
|
-
skipped: true,
|
|
538
|
-
skipReason: "already_extracted",
|
|
539
|
-
});
|
|
540
|
-
skippedCount += 1;
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
677
|
// Per-run cap on LLM-processed sessions (skip-tracked seen sessions above
|
|
544
678
|
// don't count). Single-session / --force modes bypass the cap (explicit
|
|
545
679
|
// intent). Overflow sessions are left unseen for the next run.
|
|
@@ -548,17 +682,36 @@ export async function akmExtract(options) {
|
|
|
548
682
|
break;
|
|
549
683
|
}
|
|
550
684
|
try {
|
|
551
|
-
const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, sessionIndexing);
|
|
685
|
+
const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true, typeof options.sessionId === "string" && options.sessionId.length > 0);
|
|
552
686
|
sessions.push(result);
|
|
687
|
+
// #626 — triage aggregation. A session reached the triage gate only when it
|
|
688
|
+
// was NOT already preempted by an earlier skip (read_failed / too_short /
|
|
689
|
+
// already_extracted handled above the processSession call). When triage is
|
|
690
|
+
// enabled, processSession either triages-out (skipReason 'triaged_out') or
|
|
691
|
+
// proceeds past the gate — both count as "evaluated".
|
|
692
|
+
if (triage.enabled) {
|
|
693
|
+
const preemptedBeforeTriage = result.skipReason === "read_failed" ||
|
|
694
|
+
result.skipReason === "too_short" ||
|
|
695
|
+
result.skipReason === "already_extracted";
|
|
696
|
+
if (!preemptedBeforeTriage) {
|
|
697
|
+
triageEvaluated += 1;
|
|
698
|
+
if (result.skipReason === "triaged_out")
|
|
699
|
+
triagedOut += 1;
|
|
700
|
+
else
|
|
701
|
+
triagePassed += 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
553
704
|
if (result.skipped)
|
|
554
705
|
skippedCount += 1;
|
|
555
706
|
else
|
|
556
707
|
processedCount += 1;
|
|
557
708
|
allProposalIds.push(...result.proposalIds);
|
|
558
|
-
// Persist outcome so the next run skips this session unless
|
|
559
|
-
//
|
|
560
|
-
// and should never poison the seen-table.
|
|
561
|
-
|
|
709
|
+
// Persist outcome so the next run skips this session unless its content
|
|
710
|
+
// changes. We only track non-dry-run paths — dry-run is for inspection
|
|
711
|
+
// and should never poison the seen-table. #602: an `already_extracted`
|
|
712
|
+
// skip is a no-op (the row already carries the matching hash), so don't
|
|
713
|
+
// re-write it — that keeps `processed_at` stable across unchanged runs.
|
|
714
|
+
if (trackingEnabled && stateDb && !dryRun && result.skipReason !== "already_extracted") {
|
|
562
715
|
try {
|
|
563
716
|
const outcome = result.skipped
|
|
564
717
|
? result.skipReason === "read_failed" || result.skipReason === "exception"
|
|
@@ -577,6 +730,10 @@ export async function akmExtract(options) {
|
|
|
577
730
|
proposalCount: result.proposalIds.length,
|
|
578
731
|
rationale: result.rationaleIfEmpty ?? null,
|
|
579
732
|
sourceRun,
|
|
733
|
+
// #602 — persist the freshly computed content hash so the NEXT run
|
|
734
|
+
// can compare byte-for-byte. read_failed (before hash) → null, which
|
|
735
|
+
// keeps the row eligible for retry (matches failed-row semantics).
|
|
736
|
+
contentHash: result.contentHash ?? null,
|
|
580
737
|
metadata: {
|
|
581
738
|
preFilterInputCount: result.preFilter.inputCount,
|
|
582
739
|
preFilterOutputCount: result.preFilter.outputCount,
|
|
@@ -624,6 +781,20 @@ export async function akmExtract(options) {
|
|
|
624
781
|
// best-effort close
|
|
625
782
|
}
|
|
626
783
|
}
|
|
784
|
+
// #626 — counts-only triage telemetry (AC4). Exactly ONE aggregated event per
|
|
785
|
+
// run, emitted only when the gate was enabled and actually evaluated at least
|
|
786
|
+
// one session. No per-session events (avoids the log-spam the issue warns of).
|
|
787
|
+
if (triage.enabled && triageEvaluated > 0) {
|
|
788
|
+
appendEvent({
|
|
789
|
+
eventType: "extract_triaged",
|
|
790
|
+
metadata: {
|
|
791
|
+
evaluated: triageEvaluated,
|
|
792
|
+
passed: triagePassed,
|
|
793
|
+
triagedOut,
|
|
794
|
+
sourceRun,
|
|
795
|
+
},
|
|
796
|
+
}, options.ctx);
|
|
797
|
+
}
|
|
627
798
|
return {
|
|
628
799
|
schemaVersion: 1,
|
|
629
800
|
ok: true,
|
|
@@ -645,9 +816,15 @@ export async function akmExtract(options) {
|
|
|
645
816
|
* logic in {@link akmExtract} so the `#554 minNewSessions` gate in `improve`
|
|
646
817
|
* can decide whether the extract pass is worth running before any work begins.
|
|
647
818
|
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
819
|
+
* #602 — this gate is intentionally CHEAP: it does NOT read session bodies, so
|
|
820
|
+
* it cannot compute the content hash that {@link shouldSkipAlreadyExtractedSession}
|
|
821
|
+
* now uses. It therefore uses a CONSERVATIVE row-presence approximation: a
|
|
822
|
+
* session counts as "new" when there is NO prior row OR the prior row's
|
|
823
|
+
* `content_hash` is null (never-seen or backfill-eligible). A prior row WITH a
|
|
824
|
+
* non-null content_hash counts as NOT new — it MIGHT have changed, but the
|
|
825
|
+
* precise per-session hash check happens downstream in processSession, so an
|
|
826
|
+
* over-/under-count here only affects whether the pass RUNS, never whether a
|
|
827
|
+
* changed session is actually re-processed.
|
|
651
828
|
*/
|
|
652
829
|
export function countNewExtractCandidates(config, options = {}) {
|
|
653
830
|
const extractProcess = config.profiles?.improve?.default?.processes?.extract;
|
|
@@ -681,7 +858,10 @@ export function countNewExtractCandidates(config, options = {}) {
|
|
|
681
858
|
}
|
|
682
859
|
for (const summary of candidates) {
|
|
683
860
|
const prior = seenMap.get(summary.sessionId);
|
|
684
|
-
|
|
861
|
+
// #602 row-presence approximation (see fn doc): a prior row WITH a
|
|
862
|
+
// non-null content_hash is treated as not-new here; everything else
|
|
863
|
+
// (never-seen, or null-hash backfill-eligible) counts as new.
|
|
864
|
+
if (prior && prior.content_hash != null)
|
|
685
865
|
continue;
|
|
686
866
|
total += 1;
|
|
687
867
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Symmetric valence weighting for the improve eligibility sort (#614).
|
|
6
|
+
*
|
|
7
|
+
* BACKGROUND. The improve attention/eligibility ranking historically combined
|
|
8
|
+
* utility with a NEGATIVE-ONLY feedback term: `negative / (positive + negative)`.
|
|
9
|
+
* Under that formula a strong-positive asset contributes a feedback ratio of
|
|
10
|
+
* `0` — i.e. positive feedback never drives attention. Only complaints could
|
|
11
|
+
* lift an asset up the ranking, so a heavily-praised, heavily-used asset that
|
|
12
|
+
* deserves REINFORCEMENT (distill / promote the win) is treated identically to
|
|
13
|
+
* a never-rated one.
|
|
14
|
+
*
|
|
15
|
+
* FIX (gated, default-off). When symmetric valence is enabled we replace the
|
|
16
|
+
* negative-only ratio with a |valence| MAGNITUDE term so that BOTH strong
|
|
17
|
+
* positive and strong negative feedback drive attention. Utility remains the
|
|
18
|
+
* dominant ordering factor — valence is a secondary attention nudge with a
|
|
19
|
+
* small fixed weight, never a utility override.
|
|
20
|
+
*
|
|
21
|
+
* This module is intentionally pure and storage-free: it takes pre-aggregated
|
|
22
|
+
* positive/negative counts plus a utility lookup and returns a deterministic
|
|
23
|
+
* score and lane. All DB access stays in the caller.
|
|
24
|
+
*/
|
|
25
|
+
/** Weight on utility in the combined eligibility score. Utility is dominant. */
|
|
26
|
+
export const UTILITY_WEIGHT = 0.7;
|
|
27
|
+
/** Weight on the feedback attention term in the combined eligibility score. */
|
|
28
|
+
export const FEEDBACK_WEIGHT = 0.3;
|
|
29
|
+
/**
|
|
30
|
+
* Minimum |valence| magnitude for an item to be ROUTED to the fix / reinforce
|
|
31
|
+
* lane. Below this the feedback is too weak/mixed to be a confident signal and
|
|
32
|
+
* the item carries no attention lane (`null`). Pure magnitude in [0, 1].
|
|
33
|
+
*/
|
|
34
|
+
export const STRONG_VALENCE_THRESHOLD = 0.5;
|
|
35
|
+
/**
|
|
36
|
+
* Compute the symmetric-valence attention score for one asset's feedback.
|
|
37
|
+
*
|
|
38
|
+
* Deterministic: depends only on the integer counts. No clock, no randomness.
|
|
39
|
+
*/
|
|
40
|
+
export function computeValenceScore(counts) {
|
|
41
|
+
const positive = Math.max(0, counts.positive);
|
|
42
|
+
const negative = Math.max(0, counts.negative);
|
|
43
|
+
const total = positive + negative;
|
|
44
|
+
if (total === 0) {
|
|
45
|
+
return { valence: 0, magnitude: 0, attention: 0, lane: null };
|
|
46
|
+
}
|
|
47
|
+
const valence = (positive - negative) / total;
|
|
48
|
+
const magnitude = Math.abs(valence);
|
|
49
|
+
let lane = null;
|
|
50
|
+
if (magnitude >= STRONG_VALENCE_THRESHOLD) {
|
|
51
|
+
lane = valence < 0 ? "fix" : "reinforce";
|
|
52
|
+
}
|
|
53
|
+
return { valence, magnitude, attention: magnitude, lane };
|
|
54
|
+
}
|