akm-cli 0.9.2-alpha.2 → 0.9.2-alpha.4

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.
@@ -15,15 +15,19 @@ import { listExistingTableNames, openStateDatabase } from "../core/state-db.js";
15
15
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
16
16
  import { readSemanticStatus } from "../indexer/search/semantic-status.js";
17
17
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
+ import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
18
19
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
20
+ import { pkgVersion } from "../version.js";
19
21
  import { collectImproveAdvisories } from "./health/advisories.js";
20
22
  import { HEALTH_CHECKS, runHealthEngineProbes } from "./health/checks.js";
21
23
  import { buildImproveSkipSummary, computeWallTimeStats, parseTaskMetadata, roundRate, summarizeImproveCompleted, summarizeImproveRuns, } from "./health/improve-metrics.js";
22
24
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
23
25
  import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
26
+ import { collectPluginStalenessAdvisories } from "./health/plugin-staleness.js";
24
27
  import { collectStashExposureAdvisory } from "./health/stash-exposure.js";
25
28
  import { collectSurfacesAdvisories } from "./health/surfaces.js";
26
29
  import { buildPerRunSummaries } from "./health/task-runs.js";
30
+ import { buildTypeDirectoryAdvisory } from "./health/type-directory-check.js";
27
31
  import { ACTIVE_RUN_WARN_MS, IMPROVE_COMPLETED_EVENT, MIN_ROWS_FOR_WORST_TASK_FAIL_RATE, } from "./health/types.js";
28
32
  import { buildWindowMetrics, computeDeltas, partitionLogBackedRows, resolveWindowCompare } from "./health/windows.js";
29
33
  const DEFAULT_SINCE_MS = 24 * 60 * 60 * 1000;
@@ -227,11 +231,12 @@ function gatherImproveSummaryPhase(db, stateDbPath, since, now) {
227
231
  return { improveSummary, perRunSummaries };
228
232
  }
229
233
  /**
230
- * The three best-effort advisory groups beyond the health-check registry:
231
- * improve advisories, the `stash-git-exposure` probe, and the 08 surfaces
232
- * group (binary-config-skew, egress-endpoints). Order matches emission order in
233
- * the returned array. A probe/filesystem failure in either try/catch must not
234
- * abort the health report — each group degrades to "no advisory" independently.
234
+ * The four best-effort advisory groups beyond the health-check registry:
235
+ * improve advisories, the `stash-git-exposure` probe, the 08 surfaces group
236
+ * (binary-config-skew, egress-endpoints), and `plugin-version` (itlackey/akm#832).
237
+ * Order matches emission order in the returned array. A probe/filesystem
238
+ * failure in any try/catch must not abort the health report — each group
239
+ * degrades to "no advisory" independently.
235
240
  */
236
241
  function gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, options, egressConfigView) {
237
242
  const advisories = [...collectImproveAdvisories(db, stateDbPath, since, improveSummary)];
@@ -269,8 +274,61 @@ function gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, optio
269
274
  catch {
270
275
  // Non-fatal.
271
276
  }
277
+ // #831: flag indexed assets whose resolved type disagrees with the type
278
+ // their containing directory declares (see health/type-directory-check.ts).
279
+ // Best-effort — an unreadable index must not abort the health report.
280
+ try {
281
+ const typeDirMismatch = detectTypeDirectoryDisagreements(options.stashDir ?? resolveStashDir());
282
+ if (typeDirMismatch)
283
+ advisories.push(typeDirMismatch);
284
+ }
285
+ catch {
286
+ // Non-fatal.
287
+ }
288
+ // itlackey/akm#832: report installed Claude Code harness plugin version(s)
289
+ // and warn when stale or when the plugin's own akm-cli version range no
290
+ // longer admits this CLI. Best-effort — no plugin installed, an unreadable
291
+ // manifest, or a network failure while checking the newest tag must not
292
+ // abort the health report.
293
+ try {
294
+ advisories.push(...collectPluginStalenessAdvisories({ cliVersion: pkgVersion }));
295
+ }
296
+ catch {
297
+ // Non-fatal.
298
+ }
272
299
  return advisories;
273
300
  }
301
+ /**
302
+ * Open index.db read-only, project every entry to `{ filePath, type }`, and
303
+ * build the `type-directory-disagreement` advisory. `stashRoot` is used only
304
+ * to shorten displayed paths (relative to the stash) when it's an ancestor of
305
+ * the entry's path; falls back to the absolute path otherwise. Returns
306
+ * `undefined` when the index is absent/unreadable or nothing disagrees —
307
+ * mirrors {@link detectIndexStateGenerationMismatch}'s best-effort shape.
308
+ */
309
+ function detectTypeDirectoryDisagreements(stashRoot) {
310
+ let indexDb;
311
+ try {
312
+ indexDb = openReadonlyExistingDatabase(getDbPath());
313
+ if (!indexDb)
314
+ return undefined;
315
+ const entries = getAllEntries(indexDb).map((entry) => ({ filePath: entry.filePath, type: entry.type }));
316
+ return buildTypeDirectoryAdvisory(entries, undefined, (absPath) => absPath.startsWith(stashRoot) ? path.relative(stashRoot, absPath) : absPath);
317
+ }
318
+ catch {
319
+ return undefined;
320
+ }
321
+ finally {
322
+ if (indexDb) {
323
+ try {
324
+ closeDatabase(indexDb);
325
+ }
326
+ catch {
327
+ // Best-effort advisory: a close failure must not abort health.
328
+ }
329
+ }
330
+ }
331
+ }
274
332
  /**
275
333
  * Detect the durable signature of an interrupted cross-database update.
276
334
  *
@@ -377,7 +377,20 @@ function runPreLlmSessionGates(args) {
377
377
  if (!force && shouldSkipAlreadyExtractedSession(prior, contentHash)) {
378
378
  return { skip: alreadyExtractedResult(harness.name, sessionRef.sessionId, prior, contentHash) };
379
379
  }
380
- const filtered = preFilterSession(data, {
380
+ // #840 harvest-without-prompting hybrid: the LLM prompt is built only from
381
+ // parent-origin events (folding stays as infrastructure for hashing above
382
+ // and inline-ref harvesting on `data.inlineRefs`, both of which still see
383
+ // the FULL folded stream). Subagent-origin events never reach
384
+ // `preFilterSession`, so #839's `dedupeTaskNotifications` naturally becomes
385
+ // a no-op on this path — a subagent's own event can no longer be in the
386
+ // kept set for a notification to be deduped against, leaving the parent's
387
+ // `<task-notification>` (the only surviving trace of that delegated work)
388
+ // untouched. See docs/plans/subagent-extraction-design.md §6.
389
+ const parentOriginData = {
390
+ ...data,
391
+ events: data.events.filter((e) => e.filePath === data.ref.filePath),
392
+ };
393
+ const filtered = preFilterSession(parentOriginData, {
381
394
  ...(typeof maxTotalChars === "number" ? { maxTotalChars } : {}),
382
395
  });
383
396
  // #595/#596 — minContentChars gate: skip the LLM call for sessions whose RAW
@@ -388,6 +401,14 @@ function runPreLlmSessionGates(args) {
388
401
  // fix gated on `filtered.stats.inputCount`, which is an EVENT count, not a
389
402
  // char count — this port measures actual raw chars so the threshold matches
390
403
  // the config key's documented unit.
404
+ // #840 — deliberately measured on the FULL folded `data.events` (parent +
405
+ // subagents), not the parent-origin view above: narrowing this to
406
+ // parent-origin chars would newly skip delegation-heavy sessions with a
407
+ // thin parent transcript before extraction runs at all, even though their
408
+ // subagent work is still fully harvested via `data.inlineRefs` above. The
409
+ // full-stream measurement is today's unchanged behavior, so the worst case
410
+ // this preserves is an LLM call over a small parent-only prompt, not a
411
+ // missed extraction.
391
412
  const rawContentChars = data.events.reduce((sum, event) => sum + event.text.length, 0);
392
413
  if (minContentChars > 0 && rawContentChars < minContentChars) {
393
414
  return {
@@ -7,7 +7,7 @@ import { defineJsonCommand, output, parseAllFlagValues } from "../../cli/shared.
7
7
  import { UsageError } from "../../core/errors.js";
8
8
  import { appendEvent } from "../../core/events.js";
9
9
  import { resolveUsageEventSource } from "../../indexer/usage/usage-events.js";
10
- import { buildMemoryFrontmatter, parseDuration, readMemoryContent, runAutoHeuristics, runLlmEnrich } from "../remember.js";
10
+ import { buildMemoryFrontmatter, parseDuration, readMemoryContent, runAutoHeuristics, runLlmEnrich, synthesizeMemoryDescription, } from "../remember.js";
11
11
  import { assertFlatAssetName, inferAssetName, resolveSupersedesForWrite, resolveSupersedesWriteTarget, resolveXrefsForWrite, writeMarkdownAsset, } from "./knowledge.js";
12
12
  import { akmSearch } from "./search.js";
13
13
  // ── Helper: similar memory search ────────────────────────────────────────────
@@ -189,7 +189,9 @@ export const rememberCommand = defineJsonCommand({
189
189
  // Phase 1B / Rec 7: even the zero-flag hot-path emits
190
190
  // `captureMode: hot` + `beliefState: asserted` so user-supplied
191
191
  // memories outrank background-derived ones during ranking.
192
+ // #834: `description` is synthesized deterministically (see synthesizeMemoryDescription) so the memory is indexable.
192
193
  const frontmatterBlock = buildMemoryFrontmatter({
194
+ description: synthesizeMemoryDescription(body),
193
195
  captureMode: "hot",
194
196
  beliefState: "asserted",
195
197
  });
@@ -264,6 +266,9 @@ export const rememberCommand = defineJsonCommand({
264
266
  observed_at = enriched.observed_at;
265
267
  executionNotices = enriched.notices;
266
268
  }
269
+ // #834: no --description and no --enrich-derived one — synthesize deterministically (see zero-flag path above).
270
+ if (!description)
271
+ description = synthesizeMemoryDescription(body);
267
272
  // ── Required-field check (before any write) ───────────────────────────
268
273
  // Tags remain required when the user explicitly asked for tag-bearing
269
274
  // metadata (--tag / --enrich / --description / --source / --expires).
@@ -9,6 +9,7 @@
9
9
  * CLI entry point stays focused on argument parsing + output routing.
10
10
  */
11
11
  import { serializeFrontmatter } from "../core/asset/asset-serialize.js";
12
+ import { DESCRIPTION_MAX_CHARS } from "../core/authoring-rules.js";
12
13
  import { toErrorMessage, tryReadStdinText } from "../core/common.js";
13
14
  import { loadConfig } from "../core/config/config.js";
14
15
  import { ConfigError, UsageError } from "../core/errors.js";
@@ -96,6 +97,75 @@ export function readMemoryContent(contentArg) {
96
97
  }
97
98
  return content;
98
99
  }
100
+ /**
101
+ * Split `text` into sentence-shaped chunks on `.`/`!`/`?`, swallowing any
102
+ * immediately-trailing closing quotes/brackets/repeated terminators into the
103
+ * same sentence (so `Alice said, "hi there."` ends the sentence at the
104
+ * closing quote, not the period).
105
+ *
106
+ * Ported from akm-eval's memory backend (`splitIntoSentences` /
107
+ * `firstSentencesCapped` in akm-eval/src/memory/backends/akm.ts), which
108
+ * independently arrived at this exact synthesis rule after measuring that
109
+ * akm indexes only frontmatter/heading text, never body prose — the same gap
110
+ * this fixes at the source.
111
+ */
112
+ function splitIntoSentences(text) {
113
+ const sentences = [];
114
+ let start = 0;
115
+ let i = 0;
116
+ while (i < text.length) {
117
+ const ch = text.charAt(i);
118
+ if (ch === "." || ch === "!" || ch === "?") {
119
+ let end = i + 1;
120
+ while (end < text.length && /["'”’)\]!?.]/.test(text.charAt(end)))
121
+ end += 1;
122
+ sentences.push(text.slice(start, end));
123
+ while (end < text.length && /\s/.test(text.charAt(end)))
124
+ end += 1;
125
+ start = end;
126
+ i = end;
127
+ continue;
128
+ }
129
+ i += 1;
130
+ }
131
+ if (start < text.length)
132
+ sentences.push(text.slice(start));
133
+ return sentences;
134
+ }
135
+ /**
136
+ * Deterministically synthesize a `description` from a memory body when the
137
+ * caller didn't supply one (#834): `akm remember`'s hot-capture path used to
138
+ * write memories with no `description:` and no `tags:`, and akm's indexer
139
+ * covers only synthesized frontmatter/headings — never body prose — so those
140
+ * memories were retrievable only by whatever words survived into the
141
+ * auto-generated filename. This closes that gap at write time.
142
+ *
143
+ * Skips a leading markdown heading line (if any) so the description reads as
144
+ * prose rather than repeating the title, then accumulates whole sentences
145
+ * from the body until the next one would exceed `capChars`, hard-truncating
146
+ * only if the very first sentence alone is over the cap. Pure, deterministic,
147
+ * no LLM call — `akm remember` must stay a fast local write.
148
+ */
149
+ export function synthesizeMemoryDescription(body, capChars = DESCRIPTION_MAX_CHARS) {
150
+ const withoutHeading = body.replace(/^\s*#{1,6}\s+.*(?:\r?\n)?/, "");
151
+ const trimmed = withoutHeading.trim() || body.trim();
152
+ if (!trimmed)
153
+ return "";
154
+ let out = "";
155
+ for (const raw of splitIntoSentences(trimmed)) {
156
+ const sentence = raw.trim();
157
+ if (!sentence)
158
+ continue;
159
+ const candidate = out ? `${out} ${sentence}` : sentence;
160
+ if (candidate.length > capChars) {
161
+ if (!out)
162
+ return `${candidate.slice(0, Math.max(0, capChars - 1)).trimEnd()}…`;
163
+ break;
164
+ }
165
+ out = candidate;
166
+ }
167
+ return out;
168
+ }
99
169
  /**
100
170
  * Run heuristic analysis on memory body text. Returns derived metadata
101
171
  * fields without modifying any files. Pure TS, zero network, zero latency.
@@ -101,7 +101,21 @@ const DIR_TYPE_MAP = [
101
101
  test: (ext) => ext === ".md",
102
102
  },
103
103
  ];
104
- const COMMAND_PLACEHOLDER_RE = /\$ARGUMENTS|\$[123]\b/;
104
+ /**
105
+ * `$ARGUMENTS` — unambiguous. Nothing else writes it, so finding it in a body
106
+ * is strong evidence of a command wherever the file lives.
107
+ */
108
+ const ARGUMENTS_PLACEHOLDER_RE = /\$ARGUMENTS/;
109
+ /**
110
+ * `$1` / `$2` / `$3` — AMBIGUOUS, because ordinary prose writes money the same
111
+ * way. The old combined pattern used `\$[123]\b`, and `\b` sits between the
112
+ * `2` and the comma in `$2,000`, so every note quoting a price read as a
113
+ * command (#824). Excluding a following digit, or a `.`/`,` that is itself
114
+ * followed by a digit, rules out `$1,200` / `$2,000` / `$2.50` / `$12` while
115
+ * still matching `$1.` at the end of a sentence — a period with no digit after
116
+ * it is not part of a number.
117
+ */
118
+ const NUMERIC_PLACEHOLDER_RE = /\$[123](?!\d|[.,]\d)/;
105
119
  const SMART_MD_FACTS = {
106
120
  workflow: { type: "workflow", specificity: 19 },
107
121
  toolsAgent: { type: "agent", specificity: 20 },
@@ -141,6 +155,17 @@ function matchDirectoryHint(dirName, ctx, specificity) {
141
155
  }
142
156
  return null;
143
157
  }
158
+ /**
159
+ * True when some ancestor directory already DECLARES this file's type via
160
+ * `DIR_TYPE_MAP` — the same walk `classifyByDirectory` performs. Derived from
161
+ * path fields alone, so `smartMdPathCandidates` can apply it without reading
162
+ * bytes.
163
+ */
164
+ function hasDeclaredDirType(ctx) {
165
+ if (isNestedSkillResource(ctx))
166
+ return false;
167
+ return ctx.ancestorDirs.some((dir) => matchDirectoryHint(dir, ctx, 0) !== null);
168
+ }
144
169
  function classifyByExtension(ctx) {
145
170
  if (ctx.fileName === "SKILL.md") {
146
171
  return { type: "skill", specificity: 25 };
@@ -204,7 +229,19 @@ function classifyBySmartMd(ctx) {
204
229
  return SMART_MD_FACTS.command;
205
230
  }
206
231
  }
207
- if (COMMAND_PLACEHOLDER_RE.test(body)) {
232
+ // `$ARGUMENTS` is unambiguous, so it keeps its long-standing precedence: a
233
+ // command dropped under `knowledge/` is still found as a command.
234
+ if (ARGUMENTS_PLACEHOLDER_RE.test(body)) {
235
+ return SMART_MD_FACTS.command;
236
+ }
237
+ // A NUMERIC placeholder is a guess, and a typed directory is a declaration.
238
+ // Where the two disagree the declaration wins — otherwise a note that merely
239
+ // quotes a price is retyped, which is #824: `memories/*.md` mentioning
240
+ // `$2,000` were indexed as commands, their refs moved to
241
+ // `commands/memories/<slug>`, and they left the `memories/` namespace
242
+ // entirely. Outside a typed directory there is no declaration to defer to,
243
+ // so the guess still stands.
244
+ if (NUMERIC_PLACEHOLDER_RE.test(body) && !hasDeclaredDirType(ctx)) {
208
245
  return SMART_MD_FACTS.command;
209
246
  }
210
247
  if (fm && "model" in fm) {
@@ -19,6 +19,37 @@ import { AbstractSessionLogProvider } from "../../session-logs/provider-base.js"
19
19
  function claudeProjectsDir() {
20
20
  return process.env.AKM_CLAUDE_PROJECTS_DIR ?? path.join(os.homedir(), ".claude", "projects");
21
21
  }
22
+ /**
23
+ * Directory Claude Code writes a session's subagent transcripts into, as
24
+ * `<project>/<parent-session-id>/subagents/agent-<agentId>.jsonl` (sometimes a
25
+ * level deeper, under a `workflows/<workflowId>/` subdirectory). These
26
+ * are not sessions of their own — every record inside carries the *parent's*
27
+ * `sessionId` — so they are excluded from `listSessions` and folded into the
28
+ * parent by `readSession`.
29
+ */
30
+ const SUBAGENTS_DIR = "subagents";
31
+ /**
32
+ * Provenance prefix for events read out of a subagent transcript, built from
33
+ * the `agent-<agentId>.meta.json` sidecar Claude Code writes next to it.
34
+ * Stamped onto the event text because {@link SessionEvent} has no dedicated
35
+ * field for it, and per-event (not once per transcript) so the provenance
36
+ * survives the extractor's per-event pre-filter.
37
+ */
38
+ function subagentProvenance(jsonlPath) {
39
+ let agentType;
40
+ let description;
41
+ try {
42
+ const meta = JSON.parse(fs.readFileSync(jsonlPath.replace(/\.jsonl$/, ".meta.json"), "utf8"));
43
+ if (typeof meta.agentType === "string")
44
+ agentType = meta.agentType;
45
+ if (typeof meta.description === "string")
46
+ description = meta.description;
47
+ }
48
+ catch {
49
+ // missing / unreadable sidecar — fall back to an untyped marker
50
+ }
51
+ return `[subagent:${agentType ?? "unknown"}]${description ? ` ${description}` : ""}`;
52
+ }
22
53
  /**
23
54
  * Parse a single Claude Code JSONL event into a normalized {@link SessionEvent}.
24
55
  * Returns `undefined` for events that don't carry textual content (file
@@ -120,13 +151,46 @@ export class ClaudeCodeProvider extends AbstractSessionLogProvider {
120
151
  }
121
152
  readSession(ref) {
122
153
  const stat = fs.statSync(ref.filePath);
123
- const lines = fs.readFileSync(ref.filePath, "utf8").split("\n").filter(Boolean);
154
+ const projectHint = path.basename(path.dirname(ref.filePath));
155
+ const parent = this.#readTranscript(ref.filePath, ref.sessionId, stat.mtimeMs);
156
+ const events = parent.events;
157
+ const inlineRefs = parent.inlineRefs;
158
+ // Fold in this session's subagent transcripts: they record work delegated
159
+ // *during* this session and every record inside carries this session's id,
160
+ // so they are harvested under the parent's identity.
161
+ for (const subagentPath of this.walkFiles(path.join(path.dirname(ref.filePath), path.basename(ref.filePath, ".jsonl"), SUBAGENTS_DIR), (name) => name.endsWith(".jsonl"))) {
162
+ const subagent = this.#readTranscript(subagentPath, ref.sessionId, stat.mtimeMs, subagentProvenance(subagentPath));
163
+ events.push(...subagent.events);
164
+ inlineRefs.push(...subagent.inlineRefs);
165
+ }
166
+ // Merge chronologically rather than appending: the delegated work happened
167
+ // during the parent session, consumers document events as time-ordered,
168
+ // and the pre-filter's budget pass drops from the head (oldest first),
169
+ // which only samples sensibly on a time-ordered stream.
170
+ events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
171
+ return {
172
+ ref: this.sessionRef({
173
+ sessionId: ref.sessionId,
174
+ filePath: ref.filePath,
175
+ startedAt: events[0]?.ts ?? stat.ctimeMs,
176
+ endedAt: events[events.length - 1]?.ts ?? stat.mtimeMs,
177
+ projectHint,
178
+ title: parent.title,
179
+ }),
180
+ events,
181
+ inlineRefs,
182
+ };
183
+ }
184
+ /**
185
+ * Parse one JSONL transcript (a session's own, or one of its subagents')
186
+ * into normalized events plus the inline `akm` invocations they contain.
187
+ * `provenance`, when given, is prefixed to every event's text.
188
+ */
189
+ #readTranscript(filePath, sessionId, fallbackTsMs, provenance) {
124
190
  const events = [];
125
191
  const inlineRefs = [];
126
192
  let title;
127
- let firstTsMs;
128
- let lastTsMs;
129
- const projectHint = path.basename(path.dirname(ref.filePath));
193
+ const lines = fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean);
130
194
  for (const line of lines) {
131
195
  let entry;
132
196
  try {
@@ -141,29 +205,14 @@ export class ClaudeCodeProvider extends AbstractSessionLogProvider {
141
205
  title = entry.customTitle;
142
206
  continue;
143
207
  }
144
- const parsed = parseClaudeEvent(entry, ref.sessionId, ref.filePath, stat.mtimeMs);
208
+ const parsed = parseClaudeEvent(entry, sessionId, filePath, fallbackTsMs);
145
209
  if (!parsed)
146
210
  continue;
147
- events.push(parsed);
148
- if (firstTsMs === undefined || (parsed.ts ?? 0) < firstTsMs)
149
- firstTsMs = parsed.ts;
150
- if (lastTsMs === undefined || (parsed.ts ?? 0) > lastTsMs)
151
- lastTsMs = parsed.ts;
211
+ events.push(provenance ? { ...parsed, text: `${provenance}\n${parsed.text}` } : parsed);
152
212
  // Extract inline akm-remember/feedback invocations from this event's text.
153
213
  inlineRefs.push(...extractInlineRefMentions(parsed.text, parsed.ts));
154
214
  }
155
- return {
156
- ref: this.sessionRef({
157
- sessionId: ref.sessionId,
158
- filePath: ref.filePath,
159
- startedAt: firstTsMs ?? stat.ctimeMs,
160
- endedAt: lastTsMs ?? stat.mtimeMs,
161
- projectHint,
162
- title,
163
- }),
164
- events,
165
- inlineRefs,
166
- };
215
+ return { events, inlineRefs, ...(title ? { title } : {}) };
167
216
  }
168
217
  /**
169
218
  * Cheap metadata peek — reads the first ~4KB to grab the `custom-title`
@@ -234,8 +283,19 @@ export class ClaudeCodeProvider extends AbstractSessionLogProvider {
234
283
  }
235
284
  return result;
236
285
  }
237
- /** Session JSONL files under `dir`, excluding the shared journal file. */
238
- #walkJsonl(dir) {
239
- return this.walkFiles(dir, (name) => name.endsWith(".jsonl") && name !== "journal.jsonl");
286
+ /**
287
+ * Session JSONL files under `dir`, excluding the shared journal file and
288
+ * subagent transcripts (folded into their parent by {@link readSession}).
289
+ * Only the directories *between* the project directory and the file are
290
+ * tested for {@link SUBAGENTS_DIR}, so a session file — which always sits
291
+ * directly in its project directory — can never be excluded.
292
+ */
293
+ *#walkJsonl(dir) {
294
+ for (const filePath of this.walkFiles(dir, (name) => name.endsWith(".jsonl") && name !== "journal.jsonl")) {
295
+ const segments = path.relative(dir, filePath).split(path.sep);
296
+ if (segments.slice(1, -1).includes(SUBAGENTS_DIR))
297
+ continue;
298
+ yield filePath;
299
+ }
240
300
  }
241
301
  }
@@ -90,12 +90,155 @@ function classifyEvent(event, akmReadOnlyOps, maxLen) {
90
90
  }
91
91
  return { keep: true, event, truncated: false };
92
92
  }
93
+ /**
94
+ * A parent-side `<task-notification>` event, as Claude Code writes it into a
95
+ * session's own transcript: a `role: "user"` event whose text is (or wraps)
96
+ * `<task-notification>...<task-id>ID</task-id>...<result>TEXT</result>...</task-notification>`.
97
+ * Matched on the tags themselves (not a dedicated field) because
98
+ * {@link SessionEvent} carries no structural provenance beyond `text`/`role`/
99
+ * `filePath` — the same constraint #830 (subagent provenance) worked within.
100
+ */
101
+ const TASK_NOTIFICATION_RE = /<task-notification>[\s\S]*<\/task-notification>/;
102
+ const TASK_ID_RE = /<task-id>([^<]+)<\/task-id>/;
103
+ const RESULT_RE = /<result>([\s\S]*)<\/result>/;
104
+ const SUMMARY_RE = /<summary>([^<]*)<\/summary>/;
105
+ /** Claude Code's own `<summary>` phrasing for a finished agent: `Agent "<description>" finished`. */
106
+ const AGENT_SUMMARY_DESCRIPTION_RE = /^Agent "(.*)" finished$/;
107
+ /** Provenance {@link subagentProvenance} stamps on every folded subagent event; stripped before comparison. */
108
+ const PROVENANCE_PREFIX_RE = /^\[subagent:[^\]]*\][^\n]*\n/;
109
+ /** Claude Code's own agentId file naming: `<...>/subagents/<...>agent-<agentId>.jsonl`. */
110
+ const SUBAGENT_FILEPATH_RE = /agent-([^/\\]+?)\.jsonl$/;
111
+ /** Dice (bigram) similarity at/above this counts as "the same content" (#839). */
112
+ const DEDUPE_SIMILARITY_THRESHOLD = 0.9;
113
+ /** A handful of named-entity decodes — enough for what Claude Code escapes when it wraps `<result>` text in XML. */
114
+ function decodeXmlEntities(text) {
115
+ return text
116
+ .replace(/&lt;/g, "<")
117
+ .replace(/&gt;/g, ">")
118
+ .replace(/&quot;/g, '"')
119
+ .replace(/&#39;/g, "'")
120
+ .replace(/&amp;/g, "&");
121
+ }
122
+ /** Sørensen–Dice coefficient over character bigrams — a cheap, symmetric textual-overlap measure. */
123
+ function diceSimilarity(a, b) {
124
+ if (a.length < 2 || b.length < 2)
125
+ return a === b ? 1 : 0;
126
+ const bigrams = (s) => {
127
+ const counts = new Map();
128
+ for (let i = 0; i < s.length - 1; i++) {
129
+ const bg = s.slice(i, i + 2);
130
+ counts.set(bg, (counts.get(bg) ?? 0) + 1);
131
+ }
132
+ return counts;
133
+ };
134
+ const bigramsA = bigrams(a);
135
+ const bigramsB = bigrams(b);
136
+ let intersection = 0;
137
+ let totalA = 0;
138
+ let totalB = 0;
139
+ for (const count of bigramsA.values())
140
+ totalA += count;
141
+ for (const count of bigramsB.values())
142
+ totalB += count;
143
+ for (const [bg, count] of bigramsA) {
144
+ const other = bigramsB.get(bg);
145
+ if (other)
146
+ intersection += Math.min(count, other);
147
+ }
148
+ return totalA + totalB === 0 ? 1 : (2 * intersection) / (totalA + totalB);
149
+ }
150
+ /**
151
+ * Stub out a parent's `<task-notification>` event when its `<result>` is a
152
+ * near-duplicate of a subagent transcript's own event that ALSO survived
153
+ * into this same kept set (#839).
154
+ *
155
+ * After #830 folds a session's subagent transcripts into its event stream,
156
+ * a completed subagent's report can appear twice: once as the subagent's own
157
+ * folded final event, once as the parent's `<task-notification>` record of
158
+ * that same call — the notification wraps the subagent's own text almost
159
+ * verbatim (Claude Code XML-escapes `<`/`>`/`&`/quotes in the `<result>`
160
+ * body, which {@link decodeXmlEntities} reverses before comparing). Direction
161
+ * is owner-decided (#839): drop the parent's copy, keep the subagent's
162
+ * original — the inverse was evaluated and rejected in #836 because some
163
+ * subagent transcripts consist ONLY of their terminal event, so dropping it
164
+ * would destroy the harvesting #830 added.
165
+ *
166
+ * **Runs on `kept` — the FINAL post-budget list — not the raw stream**, and
167
+ * only stubs a notification when a matching subagent event is ALSO present
168
+ * in that same `kept` list. This is required, not incidental: the recency-
169
+ * biased budget already evicts one side of most raw duplicate pairs before
170
+ * dedupe would matter (#840's design doc measured zero pairs where both
171
+ * copies reached the pre-dedupe prompt across four real sessions), and any
172
+ * future prompt-composition design that stops including subagent-origin
173
+ * events in the prompt at all (#840's recommended "harvest-without-
174
+ * prompting hybrid") makes the parent's `<task-notification>` the ONLY
175
+ * surviving trace of that delegated work. An unconditional raw-stream stub
176
+ * would delete that sole copy the moment the subagent's own event is absent
177
+ * for ANY reason — evicted by budget today, or never present by design
178
+ * tomorrow. Scoping to "both sides survived into the same kept set" makes
179
+ * this dedupe a no-op whenever there is only one copy left to dedupe
180
+ * against, which is exactly the case where deleting it would be a bug, not
181
+ * a fix.
182
+ *
183
+ * Matching is scoped by `<task-id>` (which is the subagent's agentId) to the
184
+ * SPECIFIC subagent transcript it names, via the `agent-<agentId>.jsonl`
185
+ * filename #830's folding already stamps onto every folded event's
186
+ * `filePath` — then requires the decoded `<result>` to be a near-duplicate
187
+ * (Dice similarity ≥ {@link DEDUPE_SIMILARITY_THRESHOLD}) of that subagent's
188
+ * text, not merely a same-agent match. This matters because a task-notification
189
+ * fires every time an agent stops (Claude Code's own note in the event: "the
190
+ * same task-id may notify more than once") — an EARLIER notification for a
191
+ * resumed agent can carry a genuinely different (intermediate) result that
192
+ * must NOT be stubbed just because the ids line up.
193
+ *
194
+ * The event is kept (not dropped) so event counts/timestamps stay stable and
195
+ * the parent's narrative — *why* it delegated — survives as a short stub:
196
+ * `[subagent <agentId> completed: <description>]`.
197
+ */
198
+ function dedupeTaskNotifications(kept) {
199
+ // Index the KEPT subagent events by the agentId embedded in their
200
+ // transcript's filename, so a notification's <task-id> narrows the
201
+ // comparison to the ONE subagent it reports on — and so an agentId with no
202
+ // surviving event here means "nothing to dedupe against", not "assume it
203
+ // exists upstream".
204
+ const byAgentId = new Map();
205
+ for (const event of kept) {
206
+ const agentId = event.filePath?.match(SUBAGENT_FILEPATH_RE)?.[1];
207
+ if (!agentId)
208
+ continue;
209
+ const list = byAgentId.get(agentId);
210
+ if (list)
211
+ list.push(event);
212
+ else
213
+ byAgentId.set(agentId, [event]);
214
+ }
215
+ if (byAgentId.size === 0)
216
+ return kept; // no folded subagent survived the budget — nothing to dedupe
217
+ return kept.map((event) => {
218
+ if (event.role !== "user" || !TASK_NOTIFICATION_RE.test(event.text))
219
+ return event;
220
+ const taskId = event.text.match(TASK_ID_RE)?.[1];
221
+ const resultRaw = event.text.match(RESULT_RE)?.[1];
222
+ if (!taskId || !resultRaw)
223
+ return event; // no <result> (e.g. a background-command notification) — nothing to compare
224
+ const candidates = byAgentId.get(taskId);
225
+ if (!candidates || candidates.length === 0)
226
+ return event; // that subagent's own event didn't survive into this kept set
227
+ const decodedResult = decodeXmlEntities(resultRaw);
228
+ const isDuplicate = candidates.some((c) => diceSimilarity(decodedResult, c.text.replace(PROVENANCE_PREFIX_RE, "")) >= DEDUPE_SIMILARITY_THRESHOLD);
229
+ if (!isDuplicate)
230
+ return event;
231
+ const summary = event.text.match(SUMMARY_RE)?.[1]?.trim();
232
+ const description = (summary && (summary.match(AGENT_SUMMARY_DESCRIPTION_RE)?.[1] ?? summary)) || "completed";
233
+ return { ...event, text: `[subagent ${taskId} completed: ${description}]` };
234
+ });
235
+ }
93
236
  export function preFilterSession(data, options = {}) {
94
237
  const akmReadOnlyOps = options.akmReadOnlyOps ?? DEFAULT_AKM_READONLY_OPS;
95
238
  const maxLen = options.maxEventTextLength ?? DEFAULT_MAX_EVENT_LENGTH;
96
239
  const maxTotalChars = options.maxTotalChars ?? DEFAULT_MAX_TOTAL_CHARS;
97
240
  const droppedByRule = {};
98
- const kept = [];
241
+ let kept = [];
99
242
  let truncatedCount = 0;
100
243
  const candidates = [];
101
244
  for (const event of data.events) {
@@ -138,6 +281,13 @@ export function preFilterSession(data, options = {}) {
138
281
  if (c.truncated)
139
282
  truncatedCount += 1;
140
283
  }
284
+ // Post-pass (#839): dedupe a task-notification against a subagent event
285
+ // ONLY when both survived into this exact kept set — see
286
+ // dedupeTaskNotifications's doc for why that scoping is required. Recompute
287
+ // totalChars afterward since stubbing can only shrink kept text, never move
288
+ // anything across the budget boundary already decided above.
289
+ kept = dedupeTaskNotifications(kept);
290
+ const finalTotalChars = kept.reduce((sum, e) => sum + e.text.length, 0);
141
291
  return {
142
292
  events: kept,
143
293
  stats: {
@@ -145,7 +295,7 @@ export function preFilterSession(data, options = {}) {
145
295
  outputCount: kept.length,
146
296
  droppedByRule,
147
297
  truncatedCount,
148
- totalChars,
298
+ totalChars: finalTotalChars,
149
299
  budgetDroppedCount,
150
300
  },
151
301
  };
@@ -41,3 +41,7 @@ export function isSemverRange(input) {
41
41
  export function maxSatisfying(versions, range) {
42
42
  return semver.maxSatisfying(versions, range) ?? undefined;
43
43
  }
44
+ /** True when `version` satisfies `range` (both real semver forms). False for an invalid version or range. */
45
+ export function satisfiesRange(version, range) {
46
+ return semver.satisfies(version, range);
47
+ }