akm-cli 0.9.0-beta.2 → 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.
Files changed (111) hide show
  1. package/CHANGELOG.md +614 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/default.html +78 -0
  16. package/dist/assets/templates/html/health.html +730 -0
  17. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  18. package/dist/cli/shared.js +21 -5
  19. package/dist/cli.js +47 -5
  20. package/dist/commands/agent/contribute-cli.js +16 -3
  21. package/dist/commands/feedback-cli.js +15 -6
  22. package/dist/commands/graph/graph.js +75 -71
  23. package/dist/commands/health/checks.js +48 -0
  24. package/dist/commands/health/html-report.js +790 -0
  25. package/dist/commands/health.js +478 -15
  26. package/dist/commands/improve/calibration.js +161 -0
  27. package/dist/commands/improve/consolidate.js +634 -111
  28. package/dist/commands/improve/dedup.js +482 -0
  29. package/dist/commands/improve/distill.js +145 -69
  30. package/dist/commands/improve/encoding-salience.js +205 -0
  31. package/dist/commands/improve/extract-cli.js +115 -1
  32. package/dist/commands/improve/extract-prompt.js +33 -2
  33. package/dist/commands/improve/extract-watch.js +140 -0
  34. package/dist/commands/improve/extract.js +280 -35
  35. package/dist/commands/improve/feedback-valence.js +54 -0
  36. package/dist/commands/improve/homeostatic.js +467 -0
  37. package/dist/commands/improve/improve-auto-accept.js +139 -6
  38. package/dist/commands/improve/improve-profiles.js +12 -0
  39. package/dist/commands/improve/improve.js +1851 -515
  40. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  41. package/dist/commands/improve/outcome-loop.js +256 -0
  42. package/dist/commands/improve/proactive-maintenance.js +87 -0
  43. package/dist/commands/improve/procedural.js +409 -0
  44. package/dist/commands/improve/recombine.js +488 -0
  45. package/dist/commands/improve/reflect-noise.js +0 -0
  46. package/dist/commands/improve/reflect.js +51 -1
  47. package/dist/commands/improve/related-sessions.js +120 -0
  48. package/dist/commands/improve/salience.js +386 -0
  49. package/dist/commands/improve/triage.js +95 -0
  50. package/dist/commands/lint/agent-linter.js +19 -24
  51. package/dist/commands/lint/base-linter.js +173 -60
  52. package/dist/commands/lint/command-linter.js +19 -24
  53. package/dist/commands/lint/env-key-rules.js +34 -1
  54. package/dist/commands/lint/index.js +30 -13
  55. package/dist/commands/lint/memory-linter.js +1 -1
  56. package/dist/commands/lint/registry.js +5 -2
  57. package/dist/commands/lint/task-linter.js +3 -3
  58. package/dist/commands/lint/workflow-linter.js +26 -1
  59. package/dist/commands/proposal/drain.js +73 -6
  60. package/dist/commands/proposal/proposal-cli.js +22 -10
  61. package/dist/commands/proposal/proposal.js +17 -1
  62. package/dist/commands/proposal/validators/proposals.js +369 -329
  63. package/dist/commands/read/curate.js +294 -79
  64. package/dist/commands/read/search-cli.js +7 -0
  65. package/dist/commands/read/search.js +1 -0
  66. package/dist/commands/remember.js +6 -2
  67. package/dist/commands/sources/installed-stashes.js +5 -1
  68. package/dist/commands/sources/stash-cli.js +10 -2
  69. package/dist/core/asset/frontmatter.js +166 -167
  70. package/dist/core/asset/markdown.js +8 -0
  71. package/dist/core/config/config-schema.js +241 -0
  72. package/dist/core/config/config.js +2 -2
  73. package/dist/core/logs-db.js +305 -0
  74. package/dist/core/paths.js +3 -0
  75. package/dist/core/state-db.js +706 -42
  76. package/dist/indexer/db/db.js +347 -38
  77. package/dist/indexer/db/graph-db.js +81 -86
  78. package/dist/indexer/ensure-index.js +152 -17
  79. package/dist/indexer/graph/graph-boost.js +51 -41
  80. package/dist/indexer/index-writer-lock.js +99 -0
  81. package/dist/indexer/indexer.js +114 -111
  82. package/dist/indexer/passes/memory-inference.js +71 -25
  83. package/dist/indexer/passes/staleness-detect.js +2 -5
  84. package/dist/indexer/search/db-search.js +15 -4
  85. package/dist/indexer/search/ranking.js +4 -0
  86. package/dist/integrations/harnesses/claude/session-log.js +27 -5
  87. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  88. package/dist/integrations/session-logs/index.js +16 -0
  89. package/dist/llm/client.js +38 -4
  90. package/dist/llm/embedder.js +27 -3
  91. package/dist/llm/embedders/local.js +66 -2
  92. package/dist/llm/graph-extract.js +2 -1
  93. package/dist/llm/memory-infer.js +4 -8
  94. package/dist/llm/metadata-enhance.js +9 -1
  95. package/dist/llm/usage-persist.js +77 -0
  96. package/dist/llm/usage-telemetry.js +103 -0
  97. package/dist/output/context.js +3 -2
  98. package/dist/output/html-render.js +73 -0
  99. package/dist/output/shapes/curate.js +14 -2
  100. package/dist/output/shapes/helpers.js +17 -1
  101. package/dist/output/text/helpers.js +78 -1
  102. package/dist/runtime.js +25 -1
  103. package/dist/scripts/migrate-storage.js +1194 -607
  104. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +455 -270
  105. package/dist/sources/providers/tar-utils.js +16 -8
  106. package/dist/storage/sqlite-pragmas.js +146 -0
  107. package/dist/tasks/runner.js +99 -16
  108. package/dist/workflows/db.js +5 -2
  109. package/dist/workflows/validate-summary.js +2 -7
  110. package/docs/data-and-telemetry.md +1 -0
  111. package/package.json +7 -5
@@ -36,12 +36,33 @@ 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";
45
+ import { resolveProcessEnabled } from "./improve-profiles.js";
42
46
  import { buildSessionSummaryPrompt, parseSessionSummary, SESSION_SUMMARY_JSON_SCHEMA, sessionMeetsDurationGate, writeSessionAsset, } from "./session-asset.js";
47
+ import { resolveTriageConfig, scoreSessionTriage } from "./triage.js";
43
48
  /** Default minimum session duration (minutes) for session indexing (#561). */
44
49
  const DEFAULT_MIN_SESSION_DURATION_MINUTES = 5;
50
+ /**
51
+ * Default minimum raw session size (chars) below which the extract LLM call is
52
+ * skipped (#595/#596). Deliberately tiny: analysis of 218 candidate-producing
53
+ * sessions showed sessions of 22–368 raw chars regularly yield 1–5 candidates,
54
+ * so size is not a reliable proxy for value — only truly empty sessions
55
+ * (0 chars, journal files) are safe to skip.
56
+ */
57
+ const DEFAULT_MIN_CONTENT_CHARS = 10;
58
+ /**
59
+ * Default cap on NEW sessions the extract pass will LLM-process in a single run
60
+ * (`processes.extract.maxSessionsPerRun` overrides; `0` disables). Bounds per-run
61
+ * wall time + token spend so a backlog of accumulated sessions can't run a single
62
+ * pass past its scheduled-task timeout. Overflow sessions stay unseen and are
63
+ * processed by subsequent runs, so coverage is preserved — just spread out.
64
+ */
65
+ const DEFAULT_MAX_SESSIONS_PER_RUN = 25;
45
66
  // ── Helpers ──────────────────────────────────────────────────────────────────
46
67
  /**
47
68
  * Parse a since-string into an absolute ms-epoch cutoff. Accepts:
@@ -104,9 +125,43 @@ function buildCandidateProposal(candidate, sourceRef) {
104
125
  if (candidate.type === "lesson" && candidate.when_to_use) {
105
126
  fm.when_to_use = candidate.when_to_use;
106
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
+ }
107
137
  const content = assembleAsset(fm, candidate.body);
108
138
  return { ref, content, description };
109
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
+ }
110
165
  /**
111
166
  * Process one session through the full pipeline: read → pre-filter → LLM →
112
167
  * parse → createProposal-per-candidate. Returns the per-session result.
@@ -115,7 +170,16 @@ function buildCandidateProposal(candidate, sourceRef) {
115
170
  * proposal validation failure) the session result records a warning and
116
171
  * keeps going — one session's bad luck never aborts a multi-session run.
117
172
  */
118
- async function processSession(harness, sessionRef, stashDir, config, llmConfig, chat, ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, sessionIndexing) {
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) {
119
183
  const warnings = [];
120
184
  let data;
121
185
  try {
@@ -133,9 +197,79 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
133
197
  skipReason: "read_failed",
134
198
  };
135
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
+ }
136
219
  const filtered = preFilterSession(data, {
137
220
  ...(typeof maxTotalChars === "number" ? { maxTotalChars } : {}),
138
221
  });
222
+ // #595/#596 — minContentChars gate: skip the LLM call for sessions whose RAW
223
+ // size is below threshold. Measured on the raw event text BEFORE the noise
224
+ // pre-filter, NOT on post-filter output — the pre-filter strips boilerplate
225
+ // so aggressively that even signal-bearing sessions can have tiny output
226
+ // (#596: gating post-filter filtered out 100% of sessions). Note: the 0.8.x
227
+ // fix gated on `filtered.stats.inputCount`, which is an EVENT count, not a
228
+ // char count — this port measures actual raw chars so the threshold matches
229
+ // the config key's documented unit.
230
+ const rawContentChars = data.events.reduce((sum, event) => sum + event.text.length, 0);
231
+ if (minContentChars > 0 && rawContentChars < minContentChars) {
232
+ return {
233
+ sessionId: sessionRef.sessionId,
234
+ harness: harness.name,
235
+ candidateCount: 0,
236
+ proposalIds: [],
237
+ preFilter: {
238
+ inputCount: filtered.stats.inputCount,
239
+ outputCount: filtered.stats.outputCount,
240
+ truncatedCount: filtered.stats.truncatedCount,
241
+ },
242
+ warnings: [],
243
+ skipped: true,
244
+ skipReason: "too_short",
245
+ contentHash,
246
+ };
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
+ }
139
273
  const prompt = buildExtractPrompt({ data, events: filtered.events, inlineRefs: data.inlineRefs });
140
274
  // #561 — ADDITIVE session indexing. Generate + write the session asset
141
275
  // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only records a
@@ -184,6 +318,7 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
184
318
  warnings: ["session_extraction feature returned empty (disabled / timeout / error)"],
185
319
  skipped: true,
186
320
  skipReason: "llm_unavailable",
321
+ contentHash,
187
322
  };
188
323
  }
189
324
  const payload = parseExtractPayload(llmRaw);
@@ -215,15 +350,34 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
215
350
  truncatedCount: filtered.stats.truncatedCount,
216
351
  },
217
352
  warnings,
353
+ contentHash,
218
354
  ...sessionAsset,
219
355
  };
220
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;
221
363
  for (const candidate of payload.candidates) {
222
364
  if (dryRun) {
223
365
  proposalIds.push(`dry-run:${candidate.type}:${candidate.name}`);
224
366
  continue;
225
367
  }
226
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);
227
381
  const { ref, content, description } = buildCandidateProposal(candidate, sessionRef);
228
382
  const result = createProposal(stashDir, {
229
383
  ref,
@@ -234,9 +388,22 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
234
388
  frontmatter: {
235
389
  description,
236
390
  ...(candidate.when_to_use ? { when_to_use: candidate.when_to_use } : {}),
237
- ...(typeof candidate.confidence === "number" ? { confidence: candidate.confidence } : {}),
391
+ ...(effectiveConfidence !== undefined ? { confidence: effectiveConfidence } : {}),
238
392
  sources: [`session:${sessionRef.harness}:${sessionRef.sessionId}`],
239
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() : {}),
240
407
  },
241
408
  },
242
409
  }, ctx);
@@ -277,6 +444,7 @@ async function processSession(harness, sessionRef, stashDir, config, llmConfig,
277
444
  truncatedCount: filtered.stats.truncatedCount,
278
445
  },
279
446
  warnings,
447
+ contentHash,
280
448
  ...sessionAsset,
281
449
  };
282
450
  }
@@ -290,13 +458,20 @@ export async function akmExtract(options) {
290
458
  const stashDir = options.stashDir ?? resolveStashDir();
291
459
  const dryRun = options.dryRun ?? false;
292
460
  const sourceRun = options.sourceRun ?? `extract-${timestampForFilename()}`;
293
- // Read the per-process extract config from the active improve profile. Matches
294
- // the pattern reflect/distill/consolidate use: `profiles.improve.<active>.processes.extract`.
295
- // Only the `default` improve profile is consulted here extract isn't invoked
296
- // with a profile flag yet (parity item for a future change).
297
- const extractProcess = config.profiles?.improve?.default?.processes?.extract;
461
+ // Read the per-process extract config + enabled-state from the ACTIVE improve
462
+ // profile when `akmImprove` threaded it; otherwise fall back to the `default`
463
+ // profile (the explicit `akm extract` path, which has no active profile). This
464
+ // is what stops a non-default profile's `extract.enabled` from being silently
465
+ // overridden by the default profile and vice-versa.
466
+ const activeProfile = options.improveProfile;
467
+ const extractProcess = activeProfile
468
+ ? activeProfile.processes?.extract
469
+ : config.profiles?.improve?.default?.processes?.extract;
470
+ const extractEnabled = activeProfile
471
+ ? resolveProcessEnabled("extract", activeProfile)
472
+ : isLlmFeatureEnabled(config, "session_extraction");
298
473
  // Feature-gate early so we get a clean "skipped because disabled" envelope.
299
- if (!isLlmFeatureEnabled(config, "session_extraction")) {
474
+ if (!extractEnabled) {
300
475
  return {
301
476
  schemaVersion: 1,
302
477
  ok: true,
@@ -339,8 +514,20 @@ export async function akmExtract(options) {
339
514
  60_000;
340
515
  // Pre-filter budget — process config can raise it for large-context models.
341
516
  const maxTotalChars = typeof extractProcess?.maxTotalChars === "number" ? extractProcess.maxTotalChars : undefined;
517
+ // #595/#596 — minimum raw session size; sessions below it skip the LLM call
518
+ // entirely. Set `processes.extract.minContentChars: 0` to disable the gate.
519
+ const minContentChars = typeof extractProcess?.minContentChars === "number" ? extractProcess.minContentChars : DEFAULT_MIN_CONTENT_CHARS;
520
+ // Cap on NEW sessions LLM-processed per run; 0 disables. Absent = default.
521
+ // Bounds per-run wall time / LLM cost so a backlog can't push a run past its
522
+ // task timeout — the overflow stays unseen and is picked up by later runs.
523
+ const maxSessionsPerRun = typeof extractProcess?.maxSessionsPerRun === "number"
524
+ ? extractProcess.maxSessionsPerRun
525
+ : DEFAULT_MAX_SESSIONS_PER_RUN;
342
526
  // Default discovery window — process config can override the built-in 24h.
343
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);
344
531
  // #561 — resolve session-indexing config. Default ON: we only reach this code
345
532
  // when `session_extraction` is enabled AND an LLM is configured (both checked
346
533
  // above), so defaulting on costs nothing offline (the summary call fails open)
@@ -438,6 +625,10 @@ export async function akmExtract(options) {
438
625
  const sessions = [];
439
626
  let processedCount = 0;
440
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;
441
632
  const allProposalIds = [];
442
633
  const topLevelWarnings = [];
443
634
  const chat = options.chat ?? chatCompletion;
@@ -461,39 +652,66 @@ export async function akmExtract(options) {
461
652
  stateDb = undefined;
462
653
  }
463
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
+ }
464
671
  for (const summary of candidates) {
465
- // Skip-tracking: if this session was already processed AND no new events
466
- // have arrived since (live endedAt <= recorded endedAt), don't burn an LLM
467
- // call. --force or single-session mode (explicit sessionId) bypasses.
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.
468
676
  const prior = seenMap.get(summary.sessionId);
469
- if (!options.force && !options.sessionId && shouldSkipAlreadyExtractedSession(prior, summary.endedAt)) {
470
- sessions.push({
471
- sessionId: summary.sessionId,
472
- harness: harness.name,
473
- candidateCount: 0,
474
- proposalIds: [],
475
- preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
476
- warnings: [
477
- `already extracted at ${prior?.processed_at}; pass --force to re-process or wait until the session has new content`,
478
- ],
479
- skipped: true,
480
- skipReason: "already_extracted",
481
- });
482
- skippedCount += 1;
483
- continue;
677
+ // Per-run cap on LLM-processed sessions (skip-tracked seen sessions above
678
+ // don't count). Single-session / --force modes bypass the cap (explicit
679
+ // intent). Overflow sessions are left unseen for the next run.
680
+ if (!options.sessionId && !options.force && maxSessionsPerRun > 0 && processedCount >= maxSessionsPerRun) {
681
+ topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${candidates.length - processedCount - skippedCount} session(s) deferred to a later run.`);
682
+ break;
484
683
  }
485
684
  try {
486
- const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, 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);
487
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
+ }
488
704
  if (result.skipped)
489
705
  skippedCount += 1;
490
706
  else
491
707
  processedCount += 1;
492
708
  allProposalIds.push(...result.proposalIds);
493
- // Persist outcome so the next run skips this session unless new events
494
- // arrive. We only track non-dry-run paths — dry-run is for inspection
495
- // and should never poison the seen-table.
496
- if (trackingEnabled && stateDb && !dryRun) {
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") {
497
715
  try {
498
716
  const outcome = result.skipped
499
717
  ? result.skipReason === "read_failed" || result.skipReason === "exception"
@@ -512,6 +730,10 @@ export async function akmExtract(options) {
512
730
  proposalCount: result.proposalIds.length,
513
731
  rationale: result.rationaleIfEmpty ?? null,
514
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,
515
737
  metadata: {
516
738
  preFilterInputCount: result.preFilter.inputCount,
517
739
  preFilterOutputCount: result.preFilter.outputCount,
@@ -559,6 +781,20 @@ export async function akmExtract(options) {
559
781
  // best-effort close
560
782
  }
561
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
+ }
562
798
  return {
563
799
  schemaVersion: 1,
564
800
  ok: true,
@@ -580,9 +816,15 @@ export async function akmExtract(options) {
580
816
  * logic in {@link akmExtract} so the `#554 minNewSessions` gate in `improve`
581
817
  * can decide whether the extract pass is worth running before any work begins.
582
818
  *
583
- * A session is a "new candidate" when it is in the `since` window AND it would
584
- * not be skipped by {@link shouldSkipAlreadyExtractedSession} (i.e. it has never
585
- * been extracted, or new events have arrived since it was last extracted).
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.
586
828
  */
587
829
  export function countNewExtractCandidates(config, options = {}) {
588
830
  const extractProcess = config.profiles?.improve?.default?.processes?.extract;
@@ -616,7 +858,10 @@ export function countNewExtractCandidates(config, options = {}) {
616
858
  }
617
859
  for (const summary of candidates) {
618
860
  const prior = seenMap.get(summary.sessionId);
619
- if (shouldSkipAlreadyExtractedSession(prior, summary.endedAt))
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)
620
865
  continue;
621
866
  total += 1;
622
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
+ }