akm-cli 0.9.2-alpha.3 → 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.
- package/CHANGELOG.md +201 -0
- package/dist/commands/health/plugin-staleness.js +219 -0
- package/dist/commands/health/type-directory-check.js +167 -0
- package/dist/commands/health.js +63 -5
- package/dist/commands/improve/extract.js +22 -1
- package/dist/commands/read/remember-cli.js +6 -1
- package/dist/commands/remember.js +70 -0
- package/dist/integrations/harnesses/claude/session-log.js +85 -25
- package/dist/integrations/session-logs/pre-filter.js +152 -2
- package/dist/registry/semver.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,207 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.2-alpha.4] - 2026-08-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`akm health`: flag assets whose resolved type disagrees with their
|
|
14
|
+
directory** (#837). Adds a `type-directory-disagreement` advisory that
|
|
15
|
+
compares every indexed asset's resolved type against the type its
|
|
16
|
+
`DIR_TYPE_MAP` directory declares (`memories/`, `knowledge/`, `commands/`,
|
|
17
|
+
`agents/`, `workflows/`, `facts/`, `lessons/`, `sessions/`,
|
|
18
|
+
`instructions/`, `scripts/`, `env/`, `secrets/`, `tasks/`). This is the
|
|
19
|
+
diagnostic that would have caught #824 (three `memories/` files silently
|
|
20
|
+
indexed as commands) the day it was introduced. Since `knowledge/` +
|
|
21
|
+
`$ARGUMENTS` and `agents/` + `agent:` frontmatter are deliberate command
|
|
22
|
+
overrides, the check never hard-fails: every disagreement is reported as a
|
|
23
|
+
warning naming the winning classifier signal, with a `knownGoodOverride`
|
|
24
|
+
flag so a sanctioned override reads differently from an unexplained one.
|
|
25
|
+
- **`akm health`: report the Claude harness plugin's version and warn when
|
|
26
|
+
it's stale or out of range** (#838). Adds a `plugin-version` advisory that
|
|
27
|
+
reports each installed Claude Code `akm` plugin's version, warns when a
|
|
28
|
+
newer tag is published upstream (naming the update command), and warns
|
|
29
|
+
when the plugin's own declared `AKM_VERSION_RANGE` no longer admits the
|
|
30
|
+
running CLI — meaning the plugin has silently disabled itself. Makes an
|
|
31
|
+
outbound `git ls-remote` when network is available to check for a newer
|
|
32
|
+
tag; per owner decision, this is read-only and degrades to a benign pass
|
|
33
|
+
(no plugin, no marketplace clone, unreadable manifest, malformed range, or
|
|
34
|
+
a failed remote lookup) rather than crashing or blocking offline use.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Extract: LLM prompt is now built from parent-origin events only —
|
|
39
|
+
"harvest-without-prompting hybrid" (#840).** #830 folds a session's
|
|
40
|
+
subagent transcripts into its event stream for hashing and inline-ref
|
|
41
|
+
harvesting; the prompt sent to the extraction LLM previously included that
|
|
42
|
+
folded subagent content too, competing with the parent's own transcript
|
|
43
|
+
for the 80,000-char pre-filter budget. #840's design-determination doc
|
|
44
|
+
(`docs/plans/subagent-extraction-design.md`) measured that this "fold"
|
|
45
|
+
approach evicts up to 28.6% of parent-origin content on real sessions to
|
|
46
|
+
make room for subagent noise that mostly gets evicted anyway, while a
|
|
47
|
+
"harvest-without-prompting hybrid" — keep folding for hashing/inline-ref
|
|
48
|
+
purposes, but filter the prompt down to `data.events` whose `filePath`
|
|
49
|
+
matches the session's own (`data.ref.filePath`) — matches or beats the
|
|
50
|
+
folded prompt's size with zero eviction on every session measured, and
|
|
51
|
+
recovers the exact same inline refs (`akm remember`/`akm feedback` calls
|
|
52
|
+
the agent made inside a subagent), because that harvesting already runs on
|
|
53
|
+
the raw stream independent of what reaches the prompt. Only
|
|
54
|
+
`runPreLlmSessionGates`'s call into `preFilterSession` changed; folding
|
|
55
|
+
(`session-log.ts`) and `buildExtractPrompt` are untouched.
|
|
56
|
+
- **No forced re-extraction wave.** `hashSessionContent` still hashes the
|
|
57
|
+
full folded `data` (parent + subagents), computed before the
|
|
58
|
+
parent-origin view is built — no previously-computed session hash
|
|
59
|
+
changes, so no session already extracted under the fold prompt shape is
|
|
60
|
+
automatically re-processed. Use `--force` to re-process a specific
|
|
61
|
+
session under the new, parent-only prompt shape.
|
|
62
|
+
- **`processes.extract.maxTotalChars` is unchanged in meaning and default**
|
|
63
|
+
— it still caps the single-call prompt built from parent-origin events;
|
|
64
|
+
it simply no longer has to compete against subagent-origin noise for
|
|
65
|
+
that budget.
|
|
66
|
+
- **`minContentChars`** (the raw-size skip gate, #595/#596) is still
|
|
67
|
+
measured on the FULL folded `data.events` (parent + subagents),
|
|
68
|
+
deliberately left unchanged: narrowing it to parent-origin chars would
|
|
69
|
+
newly skip delegation-heavy sessions with a thin parent transcript
|
|
70
|
+
before extraction runs at all, even though their subagent-origin work is
|
|
71
|
+
still fully harvested via inline refs. The full-stream measurement is
|
|
72
|
+
today's existing behavior; the worst case it preserves is an LLM call
|
|
73
|
+
over a small parent-only prompt, not a missed extraction.
|
|
74
|
+
- #839's task-notification dedupe (which stubs a parent's
|
|
75
|
+
`<task-notification>` only when the matching subagent's own event ALSO
|
|
76
|
+
survives into the same kept prompt set) composes safely with this
|
|
77
|
+
change without modification: subagent-origin events never reach
|
|
78
|
+
`preFilterSession` on this path, so the dedupe's own scoping check
|
|
79
|
+
naturally makes it a no-op — the parent's notification (the only
|
|
80
|
+
remaining trace of delegated work in the prompt) survives untouched.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- **`akm remember` synthesizes a description when the caller doesn't supply
|
|
85
|
+
one** (#835). Both the zero-flag hot path and the structured-args path
|
|
86
|
+
(e.g. `--tag`-only, with no `--description`/`--enrich`) previously wrote
|
|
87
|
+
memories with no `description:` and no `tags:`. akm's indexer covers only
|
|
88
|
+
synthesized frontmatter/headings, never body prose, so those memories were
|
|
89
|
+
retrievable only by whatever words survived into the auto-generated
|
|
90
|
+
filename — effectively write-only. Verified on a real stash: 272/3169
|
|
91
|
+
memories lacked a description, 100% of those written via `akm remember`.
|
|
92
|
+
The new `synthesizeMemoryDescription` (ported from akm-eval's
|
|
93
|
+
`firstSentencesCapped` rule, which independently arrived at the same fix)
|
|
94
|
+
is deterministic and makes no LLM call: it accumulates whole sentences
|
|
95
|
+
from the body up to `DESCRIPTION_MAX_CHARS`, skipping a leading markdown
|
|
96
|
+
heading so the description doesn't just repeat the title. Wired into both
|
|
97
|
+
write paths as a fallback only — a caller-supplied `--description` (or one
|
|
98
|
+
derived by `--enrich`) is never overwritten. Closes the write-only-memories
|
|
99
|
+
gap on 0.9.1 indexes.
|
|
100
|
+
- **Extract: deduped the doubled subagent conclusion in the extraction prompt**
|
|
101
|
+
(#839). After #830 folded a session's subagent transcripts into its event
|
|
102
|
+
stream, a completed subagent's final report could appear twice in the same
|
|
103
|
+
extraction prompt: once as the subagent's own folded final message, once as
|
|
104
|
+
the parent's `<task-notification>` record of that same call (#836 measured
|
|
105
|
+
~92-99% textual overlap on a real pair; reproduced here as a byte-identical
|
|
106
|
+
match after decoding the XML entities Claude Code escapes into `<result>`).
|
|
107
|
+
The parent's notification copy is now stubbed to `[subagent <agentId>
|
|
108
|
+
completed: <description>]` when its `<result>` is a near-duplicate
|
|
109
|
+
(Dice-bigram similarity ≥ 0.9) of a folded subagent transcript's own text;
|
|
110
|
+
the subagent's original is untouched, per #839's owner-decided direction
|
|
111
|
+
(the inverse — dropping the subagent's own terminal event — was evaluated
|
|
112
|
+
and rejected in #836 because some subagent transcripts consist only of
|
|
113
|
+
that one event). Matching is scoped by `<task-id>` to the one subagent
|
|
114
|
+
transcript it names and still requires content similarity, so an earlier
|
|
115
|
+
notification for a *resumed* agent (Claude Code re-notifies the same
|
|
116
|
+
task-id on each stop) that carries a genuinely different, intermediate
|
|
117
|
+
result is left alone.
|
|
118
|
+
**Scoped to the final, post-budget kept set — not the raw stream** (#840's
|
|
119
|
+
design-determination doc flagged this as a hazard while this PR was in
|
|
120
|
+
flight): the dedupe only fires when the subagent's own event ALSO survives
|
|
121
|
+
into the same kept set as the notification. #840 measured that today's
|
|
122
|
+
recency-biased 80k budget already evicts one side of nearly every raw
|
|
123
|
+
duplicate pair before dedupe would matter (0 of 89 raw pairs across four
|
|
124
|
+
real sessions had both sides survive); an unconditional raw-stream stub
|
|
125
|
+
would, under that same eviction pattern, sometimes delete a parent's
|
|
126
|
+
notification whose subagent copy never made the cut in the first place —
|
|
127
|
+
and would unconditionally delete the *only* surviving trace of delegated
|
|
128
|
+
work under #840's recommended future design (prompting from parent-origin
|
|
129
|
+
events only). Verified against the real session #836 and #839 both cite
|
|
130
|
+
(`4a0d9e9b…`): under the actual 80,000-char budget, 0 notifications are
|
|
131
|
+
stubbed today (consistent with #840's finding) because the cited pair's
|
|
132
|
+
subagent copy doesn't survive the budget; with the budget cap lifted,
|
|
133
|
+
1 of 10 raw duplicate pairs in that session both survive AND still exceed
|
|
134
|
+
the 0.9 similarity bar after the pre-filter's independent per-event
|
|
135
|
+
2000-char truncation (the other 9 exceed that per-event cap and truncate
|
|
136
|
+
down far enough to fall below the bar — a conservative miss, never a wrong
|
|
137
|
+
stub). The fix is real and correct for sessions/pairs small enough to avoid
|
|
138
|
+
both eviction and truncation, and is structurally inert wherever it would
|
|
139
|
+
be unsafe to fire.
|
|
140
|
+
Implemented in the pre-filter (`preFilterSession`), which runs AFTER
|
|
141
|
+
`hashSessionContent` — so **no `contentHash` moves and no re-extraction
|
|
142
|
+
wave is triggered** (unlike #830's own folding change, which changed the
|
|
143
|
+
raw event stream #602's hash covers).
|
|
144
|
+
- **Extract: regression-tested the no-double-extraction guarantee** (#839).
|
|
145
|
+
Discovery-mode extraction over a project with a parent + subagent
|
|
146
|
+
transcripts now has an explicit end-to-end test proving exactly one
|
|
147
|
+
session is processed, that `--session-id agent-<hash>` resolves to the
|
|
148
|
+
not-found result rather than an extraction, and that folded subagent
|
|
149
|
+
content is attributed only to the parent's session/contentHash. Pins
|
|
150
|
+
behavior already true since #830 (`listSessions()` excludes `subagents/`
|
|
151
|
+
dirs for both discovery and `--session-id` lookup); nothing tested it
|
|
152
|
+
end-to-end before.
|
|
153
|
+
|
|
154
|
+
### Documentation
|
|
155
|
+
|
|
156
|
+
- **Measured whether subagent-transcript folding (#830) duplicates the
|
|
157
|
+
parent's own summary, and disclosed the one-time re-extraction cost
|
|
158
|
+
(#833).** Using the actual reader/pre-filter/prompt-builder code against 3
|
|
159
|
+
real sessions on this machine — no LLM calls; `contentHash`,
|
|
160
|
+
`preFilterSession`, and `buildExtractPrompt` are deterministic:
|
|
161
|
+
- Raw event counts grow 2x-12x once subagent transcripts are folded in
|
|
162
|
+
(measured: 1209 -> 14224; 1583 -> 6738, the exact session cited in
|
|
163
|
+
#829/#833's "1583 -> 6738" figure; 155 -> 2408). `contentHash` is
|
|
164
|
+
computed over that stream, so every previously-extracted session's hash
|
|
165
|
+
changes and the next `--since` run re-extracts all of them once, each
|
|
166
|
+
with a larger prompt (+1.2% to +5.2% prompt chars across the 3 sessions,
|
|
167
|
+
since the 80,000-char pre-filter budget caps how much of the growth
|
|
168
|
+
actually reaches the LLM).
|
|
169
|
+
- The result is a genuine tradeoff, not a clean win or loss. **Benefit:**
|
|
170
|
+
inline `akm remember`/`akm feedback` calls made *by subagents* are
|
|
171
|
+
recovered regardless of the budget cap (inline-ref extraction runs on
|
|
172
|
+
the raw event stream, not the pre-filtered one) — up to 162 refs
|
|
173
|
+
recovered on the largest session measured (was 2 without folding),
|
|
174
|
+
fixing #829's "delegated work is never harvested" defect. **Cost:** on
|
|
175
|
+
sessions whose raw content is near or under the pre-filter's character
|
|
176
|
+
budget, folding evicts a large share of the parent's own kept content to
|
|
177
|
+
make room for subagent tool-call trace — parent-origin kept events
|
|
178
|
+
dropped 27% and 71% respectively on the two smaller sessions measured.
|
|
179
|
+
On the largest session the budget was already saturated by the parent's
|
|
180
|
+
own tail, so folding changed nothing there. Duplication is real, not
|
|
181
|
+
hypothetical: on the smallest session, one subagent's conclusion appears
|
|
182
|
+
twice in the same prompt sent to the extraction LLM — once via its own
|
|
183
|
+
folded final message, once via the parent's own record of that
|
|
184
|
+
delegated call's result, which independently already captured ~92% of
|
|
185
|
+
the same text verbatim.
|
|
186
|
+
- A narrowing that drops a subagent transcript's terminal event (its
|
|
187
|
+
apparent "final report") to avoid this specific duplication was
|
|
188
|
+
considered and rejected: the existing #830 regression fixture has a
|
|
189
|
+
subagent transcript whose *only* event is that terminal turn (a single
|
|
190
|
+
delegated `akm remember` call) — the same rule would drop the only
|
|
191
|
+
content in short single-step delegations, undoing the harvesting #830
|
|
192
|
+
added.
|
|
193
|
+
- **Decision: keep folding as shipped.** The data does not cleanly favor
|
|
194
|
+
removing or narrowing it, and the one narrowing considered would cost
|
|
195
|
+
more than it fixes. #829's phantom-session exclusion is unaffected
|
|
196
|
+
either way.
|
|
197
|
+
- Recorded the fold-vs-link subagent-extraction design determination in
|
|
198
|
+
`docs/plans/subagent-extraction-design.md` (#840). Measured four candidates
|
|
199
|
+
(fold+dedupe as shipped, link-only, a harvest-without-prompting hybrid, and
|
|
200
|
+
chunked map-reduce extraction) on the same real sessions #836 used plus one
|
|
201
|
+
added for scale. Headline: the hybrid recovers 100% of #830's inline-ref
|
|
202
|
+
harvesting (162/162, 38/38, 1/1, 8/8 across the four sessions) with zero
|
|
203
|
+
parent-content eviction (vs 27.5%/28.6% evicted under fold on two of the
|
|
204
|
+
four), and #839's dedupe was measured to have zero effect on the actual
|
|
205
|
+
LLM prompt on all four sessions (the flagged duplicate content is already
|
|
206
|
+
evicted by the recency-biased budget before dedupe would matter). Chunked
|
|
207
|
+
extraction was measured at 9x-229x more LLM calls per session on real
|
|
208
|
+
data and is not recommended. No behavior changes shipped in this PR.
|
|
209
|
+
|
|
9
210
|
## [0.9.2-alpha.3] - 2026-08-26
|
|
10
211
|
|
|
11
212
|
### Fixed
|
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
* `plugin-version` advisory for `akm health` (itlackey/akm#832).
|
|
6
|
+
*
|
|
7
|
+
* #828 was filed as a CLI bug — session extraction failing on 232/234 runs —
|
|
8
|
+
* and took a full investigation to resolve. The actual cause: the harness
|
|
9
|
+
* plugin (`akm@akm-plugins`, installed in Claude Code's plugin cache) was
|
|
10
|
+
* three days stale relative to the fix, and its own `AKM_VERSION_RANGE` gate
|
|
11
|
+
* had nothing to do with it — the plugin was simply running old code. Every
|
|
12
|
+
* fact needed to reach that conclusion in one step was already on disk:
|
|
13
|
+
*
|
|
14
|
+
* - installed plugin version: `~/.claude/plugins/cache/<marketplace>/akm/<version>/.claude-plugin/plugin.json`
|
|
15
|
+
* - the plugin's own akm-cli compatibility contract: `<pluginDir>/shared/akm-version.ts`'s `AKM_VERSION_RANGE`
|
|
16
|
+
* - the running CLI's version: `../../version.ts`'s `pkgVersion`
|
|
17
|
+
*
|
|
18
|
+
* Nothing correlated them, so a stale plugin was indistinguishable from a
|
|
19
|
+
* broken CLI. This module closes that gap with three checks, one per
|
|
20
|
+
* detected plugin:
|
|
21
|
+
*
|
|
22
|
+
* 1. report the installed version;
|
|
23
|
+
* 2. compare it against the newest tag published to the plugin's git
|
|
24
|
+
* remote, and warn (naming the update command) when behind;
|
|
25
|
+
* 3. the sharp one — check whether the *installed plugin's* declared
|
|
26
|
+
* `AKM_VERSION_RANGE` admits the *running* CLI version. When it does
|
|
27
|
+
* not, the plugin has silently disabled itself (both surfaces log
|
|
28
|
+
* `version_out_of_range` / `akm_version_mismatch` and degrade quietly)
|
|
29
|
+
* and there was previously no way to know that from the CLI side.
|
|
30
|
+
*
|
|
31
|
+
* Read-only: this never fetches, writes, or mutates the plugin cache or
|
|
32
|
+
* marketplace clone. Check 2 is the one deliberate exception to "`akm
|
|
33
|
+
* health` makes no network call" (see `./health-advisories.md`): a plugin's
|
|
34
|
+
* local marketplace clone is not proof of what is newest upstream — the
|
|
35
|
+
* incident above involved a clone that hadn't seen the fix's tag at all — so
|
|
36
|
+
* the only way to ever detect drift is to ask the remote what tags exist.
|
|
37
|
+
* That query is a `git ls-remote --tags` (lists refs; fetches nothing,
|
|
38
|
+
* writes nothing) with a short timeout, and any failure (offline, no
|
|
39
|
+
* remote, timeout) degrades to "installed version reported, no staleness
|
|
40
|
+
* claim" rather than a false positive or a hang.
|
|
41
|
+
*
|
|
42
|
+
* Every collector here is best-effort and silent on missing/unreadable
|
|
43
|
+
* input: no Claude plugin installed, no marketplace clone, an unreadable
|
|
44
|
+
* manifest, or a malformed version range must never crash `akm health` and
|
|
45
|
+
* must never produce a false "stale" or "inactive" warning.
|
|
46
|
+
*/
|
|
47
|
+
import { spawnSync } from "node:child_process";
|
|
48
|
+
import fs from "node:fs";
|
|
49
|
+
import os from "node:os";
|
|
50
|
+
import path from "node:path";
|
|
51
|
+
import { isExactSemver, isSemverRange, maxSatisfying, satisfiesRange } from "../../registry/semver.js";
|
|
52
|
+
/**
|
|
53
|
+
* Root directory holding Claude Code's plugin cache + marketplace clones.
|
|
54
|
+
* Resolved per call (not memoized) so `AKM_CLAUDE_PLUGINS_DIR` can be set
|
|
55
|
+
* after import — the override exists so tests point this at an empty
|
|
56
|
+
* fixture directory instead of the real `~/.claude/plugins`, matching
|
|
57
|
+
* `AKM_CLAUDE_PROJECTS_DIR` in `../../integrations/harnesses/claude/session-log.ts`.
|
|
58
|
+
*/
|
|
59
|
+
function claudePluginsDir() {
|
|
60
|
+
return process.env.AKM_CLAUDE_PLUGINS_DIR ?? path.join(os.homedir(), ".claude", "plugins");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Scan `<pluginsRoot>/cache/<marketplace>/akm/<version>/` for every
|
|
64
|
+
* `akm` plugin cache entry, picking the highest cached version per
|
|
65
|
+
* marketplace when more than one is present. Returns `[]` (never throws)
|
|
66
|
+
* when the cache directory is absent, empty, or unreadable — that is the
|
|
67
|
+
* ordinary "no Claude plugin installed" case, not an error.
|
|
68
|
+
*/
|
|
69
|
+
function detectInstalledPlugins(pluginsRoot) {
|
|
70
|
+
const cacheDir = path.join(pluginsRoot, "cache");
|
|
71
|
+
let marketplaces;
|
|
72
|
+
try {
|
|
73
|
+
marketplaces = fs.readdirSync(cacheDir);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const detected = [];
|
|
79
|
+
for (const marketplace of marketplaces) {
|
|
80
|
+
const pluginDir = path.join(cacheDir, marketplace, "akm");
|
|
81
|
+
let versions;
|
|
82
|
+
try {
|
|
83
|
+
versions = fs.readdirSync(pluginDir).filter(isExactSemver);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (versions.length === 0)
|
|
89
|
+
continue;
|
|
90
|
+
const latest = maxSatisfying(versions, "*") ?? versions.sort().at(-1);
|
|
91
|
+
if (!latest)
|
|
92
|
+
continue;
|
|
93
|
+
const versionDir = path.join(pluginDir, latest);
|
|
94
|
+
const manifestPath = path.join(versionDir, ".claude-plugin", "plugin.json");
|
|
95
|
+
try {
|
|
96
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
97
|
+
if (typeof manifest.version !== "string")
|
|
98
|
+
continue;
|
|
99
|
+
detected.push({
|
|
100
|
+
harness: "claude",
|
|
101
|
+
marketplace,
|
|
102
|
+
pluginName: "akm",
|
|
103
|
+
version: manifest.version,
|
|
104
|
+
pluginDir: versionDir,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Unreadable/malformed manifest — skip this entry rather than crash.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return detected;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Extract `AKM_VERSION_RANGE` from the installed plugin's vendored
|
|
115
|
+
* `shared/akm-version.ts`. Returns `undefined` when the file is missing,
|
|
116
|
+
* unreadable, or does not contain the expected declaration — callers must
|
|
117
|
+
* treat that as "compatibility unknown", never as a mismatch.
|
|
118
|
+
*/
|
|
119
|
+
function readVersionRange(pluginDir) {
|
|
120
|
+
const versionFilePath = path.join(pluginDir, "shared", "akm-version.ts");
|
|
121
|
+
let text;
|
|
122
|
+
try {
|
|
123
|
+
text = fs.readFileSync(versionFilePath, "utf8");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const match = text.match(/export\s+const\s+AKM_VERSION_RANGE\s*=\s*["']([^"']+)["']/);
|
|
129
|
+
return match?.[1];
|
|
130
|
+
}
|
|
131
|
+
const LS_REMOTE_TIMEOUT_MS = 5_000;
|
|
132
|
+
/**
|
|
133
|
+
* `git ls-remote --tags origin` against the marketplace clone's configured
|
|
134
|
+
* remote — lists refs only, fetches no objects, writes no local refs.
|
|
135
|
+
* Returns `undefined` (never throws) when the directory is not a git
|
|
136
|
+
* checkout, has no `origin` remote, or the command fails/times out (e.g.
|
|
137
|
+
* offline) — all "cannot determine availability", not "up to date".
|
|
138
|
+
*/
|
|
139
|
+
const realListRemoteTags = (marketplaceDir) => {
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = spawnSync("git", ["-C", marketplaceDir, "ls-remote", "--tags", "origin"], {
|
|
143
|
+
encoding: "utf8",
|
|
144
|
+
timeout: LS_REMOTE_TIMEOUT_MS,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (result.status !== 0 || !result.stdout)
|
|
151
|
+
return undefined;
|
|
152
|
+
const tags = result.stdout
|
|
153
|
+
.split("\n")
|
|
154
|
+
.map((line) => line.trim())
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((line) => line.split("\t")[1])
|
|
157
|
+
.filter((ref) => typeof ref === "string" && ref.startsWith("refs/tags/"))
|
|
158
|
+
.map((ref) => ref.replace(/^refs\/tags\//, "").replace(/\^\{\}$/, ""))
|
|
159
|
+
.map((tag) => tag.replace(/^v/, ""))
|
|
160
|
+
.filter(isExactSemver);
|
|
161
|
+
return [...new Set(tags)];
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Build one `plugin-version` advisory per detected `akm` harness plugin.
|
|
165
|
+
* Returns `[]` when no plugin is installed — the benign, common case.
|
|
166
|
+
*/
|
|
167
|
+
export function collectPluginStalenessAdvisories(options) {
|
|
168
|
+
const pluginsRoot = options.pluginsRoot ?? claudePluginsDir();
|
|
169
|
+
const listRemoteTags = options.listRemoteTags ?? realListRemoteTags;
|
|
170
|
+
const plugins = detectInstalledPlugins(pluginsRoot);
|
|
171
|
+
return plugins.map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags));
|
|
172
|
+
}
|
|
173
|
+
function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags) {
|
|
174
|
+
const pluginRef = `${plugin.pluginName}@${plugin.marketplace}`;
|
|
175
|
+
// Point 2: newest available vs. installed, via the marketplace clone's remote.
|
|
176
|
+
const marketplaceDir = path.join(pluginsRoot, "marketplaces", plugin.marketplace);
|
|
177
|
+
let availableVersion;
|
|
178
|
+
try {
|
|
179
|
+
const tags = fs.existsSync(marketplaceDir) ? listRemoteTags(marketplaceDir) : undefined;
|
|
180
|
+
availableVersion = tags && tags.length > 0 ? maxSatisfying(tags, "*") : undefined;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
availableVersion = undefined;
|
|
184
|
+
}
|
|
185
|
+
const stale = availableVersion !== undefined &&
|
|
186
|
+
plugin.version !== availableVersion &&
|
|
187
|
+
maxSatisfying([plugin.version, availableVersion], "*") === availableVersion;
|
|
188
|
+
// Point 3: does the plugin's own declared range admit the running CLI?
|
|
189
|
+
const versionRange = readVersionRange(plugin.pluginDir);
|
|
190
|
+
const rangeKnown = versionRange !== undefined && isSemverRange(versionRange);
|
|
191
|
+
const admitted = rangeKnown ? satisfiesRange(cliVersion, versionRange) : undefined;
|
|
192
|
+
const messageParts = [`${pluginRef}: installed ${plugin.version}`];
|
|
193
|
+
if (availableVersion !== undefined) {
|
|
194
|
+
messageParts.push(stale ? `available ${availableVersion} (STALE)` : `available ${availableVersion} (up to date)`);
|
|
195
|
+
}
|
|
196
|
+
if (stale)
|
|
197
|
+
messageParts.push(`-> claude plugin update ${pluginRef}`);
|
|
198
|
+
if (rangeKnown && admitted === false) {
|
|
199
|
+
messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED (plugin is inactive)`);
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
name: "plugin-version",
|
|
203
|
+
kind: "deterministic",
|
|
204
|
+
status: stale || admitted === false ? "warn" : "pass",
|
|
205
|
+
confidence: "high",
|
|
206
|
+
message: messageParts.join(" — "),
|
|
207
|
+
evidence: {
|
|
208
|
+
harness: plugin.harness,
|
|
209
|
+
marketplace: plugin.marketplace,
|
|
210
|
+
plugin: plugin.pluginName,
|
|
211
|
+
installedVersion: plugin.version,
|
|
212
|
+
availableVersion: availableVersion ?? null,
|
|
213
|
+
stale,
|
|
214
|
+
versionRange: versionRange ?? null,
|
|
215
|
+
cliVersion,
|
|
216
|
+
admitted: admitted ?? null,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
* `type-directory-disagreement` advisory for `akm health` (#831).
|
|
6
|
+
*
|
|
7
|
+
* The invariant "a file's directory declares its type" is load-bearing for
|
|
8
|
+
* refs, namespace listings, and `akm show` paths (see #824: three files
|
|
9
|
+
* written to `memories/` were indexed as `type: command`, moved their refs
|
|
10
|
+
* under `commands/memories/<slug>`, and silently vanished from the
|
|
11
|
+
* `memories/` namespace — nothing on any normal surface said so). This
|
|
12
|
+
* advisory re-checks that invariant against every currently indexed entry.
|
|
13
|
+
*
|
|
14
|
+
* Legitimate disagreements exist by design — a `knowledge/` file containing
|
|
15
|
+
* `$ARGUMENTS` is deliberately a `command`, and an `agents/` file with an
|
|
16
|
+
* `agent:` frontmatter key is deliberately a `command` (both asserted in
|
|
17
|
+
* `tests/integration/commands/show.test.ts`). So this is never a hard
|
|
18
|
+
* failure: every disagreement is reported with a `winner` naming which
|
|
19
|
+
* classifier signal produced the resolved type, so a deliberate override
|
|
20
|
+
* reads differently from an unexplained one.
|
|
21
|
+
*/
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
25
|
+
/**
|
|
26
|
+
* Directory → declared-type map, mirroring `DIR_TYPE_MAP` in
|
|
27
|
+
* `src/indexer/walk/matchers.ts` minus its per-directory extension test —
|
|
28
|
+
* this check only needs "which type does this directory declare", not which
|
|
29
|
+
* extensions it accepts. Keep in sync if `DIR_TYPE_MAP` gains, renames, or
|
|
30
|
+
* removes a directory.
|
|
31
|
+
*/
|
|
32
|
+
const DECLARED_DIR_TYPES = {
|
|
33
|
+
memories: "memory",
|
|
34
|
+
knowledge: "knowledge",
|
|
35
|
+
commands: "command",
|
|
36
|
+
agents: "agent",
|
|
37
|
+
workflows: "workflow",
|
|
38
|
+
facts: "fact",
|
|
39
|
+
lessons: "lesson",
|
|
40
|
+
sessions: "session",
|
|
41
|
+
instructions: "instruction",
|
|
42
|
+
scripts: "script",
|
|
43
|
+
env: "env",
|
|
44
|
+
secrets: "secret",
|
|
45
|
+
tasks: "task",
|
|
46
|
+
};
|
|
47
|
+
const realReadFile = (absPath) => {
|
|
48
|
+
try {
|
|
49
|
+
return fs.readFileSync(absPath, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The type a file's directory declares, mirroring the `classifyByDirectory` /
|
|
57
|
+
* `classifyByParentDirHint` precedence in matchers.ts: the immediate parent
|
|
58
|
+
* directory wins when it is itself typed (parentDirHint, specificity 15);
|
|
59
|
+
* otherwise the outermost typed ancestor wins (directoryMatcher, specificity
|
|
60
|
+
* 10, which walks root-to-leaf and returns on the first hit).
|
|
61
|
+
*/
|
|
62
|
+
function declaredTypeForPath(absPath) {
|
|
63
|
+
const segments = path
|
|
64
|
+
.dirname(absPath)
|
|
65
|
+
.split(path.sep)
|
|
66
|
+
.filter((seg) => seg.length > 0);
|
|
67
|
+
const immediateParent = segments.at(-1);
|
|
68
|
+
if (immediateParent) {
|
|
69
|
+
const parentType = DECLARED_DIR_TYPES[immediateParent];
|
|
70
|
+
if (parentType)
|
|
71
|
+
return { dir: immediateParent, type: parentType };
|
|
72
|
+
}
|
|
73
|
+
for (const seg of segments) {
|
|
74
|
+
const type = DECLARED_DIR_TYPES[seg];
|
|
75
|
+
if (type)
|
|
76
|
+
return { dir: seg, type };
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Best-effort explanation for why `classifyBySmartMd` (matchers.ts) would
|
|
82
|
+
* have produced `resolvedType` for this content, in the SAME precedence
|
|
83
|
+
* order the real function checks them. Returns `undefined` when no known
|
|
84
|
+
* override signal is found — that absence is itself the accident signal:
|
|
85
|
+
* nothing in the file explains why its type disagrees with its directory.
|
|
86
|
+
*
|
|
87
|
+
* The numeric-placeholder branch is flagged `knownGoodOverride: false` on
|
|
88
|
+
* purpose: since #826, that heuristic is guarded to never fire when the file
|
|
89
|
+
* sits under a declared-type directory, so seeing it win here would mean the
|
|
90
|
+
* guard regressed, not that this is a sanctioned override.
|
|
91
|
+
*/
|
|
92
|
+
function explainOverride(resolvedType, content) {
|
|
93
|
+
const fm = parseFrontmatter(content).data;
|
|
94
|
+
if (fm.type === "workflow" && resolvedType === "workflow") {
|
|
95
|
+
return { winner: "smart-md:workflow-frontmatter", knownGoodOverride: true };
|
|
96
|
+
}
|
|
97
|
+
if ("tools" in fm && resolvedType === "agent") {
|
|
98
|
+
return { winner: "smart-md:tools-frontmatter", knownGoodOverride: true };
|
|
99
|
+
}
|
|
100
|
+
if ("agent" in fm && resolvedType === "command") {
|
|
101
|
+
return { winner: "smart-md:agent-frontmatter", knownGoodOverride: true };
|
|
102
|
+
}
|
|
103
|
+
if (resolvedType === "command" && content.includes("$ARGUMENTS")) {
|
|
104
|
+
return { winner: "smart-md:$ARGUMENTS", knownGoodOverride: true };
|
|
105
|
+
}
|
|
106
|
+
if (resolvedType === "command" && /\$[123](?!\d|[.,]\d)/.test(content)) {
|
|
107
|
+
return { winner: "smart-md:numeric-placeholder", knownGoodOverride: false };
|
|
108
|
+
}
|
|
109
|
+
if ("model" in fm && resolvedType === "agent") {
|
|
110
|
+
return { winner: "smart-md:model-frontmatter", knownGoodOverride: true };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Compare every indexed entry's resolved type against the type its
|
|
116
|
+
* directory declares (see {@link DECLARED_DIR_TYPES}), and return one
|
|
117
|
+
* {@link TypeDirectoryDisagreement} per mismatch, sorted by path. Entries
|
|
118
|
+
* outside any declared-type directory are not checked — this is only the
|
|
119
|
+
* "directory declares type" invariant.
|
|
120
|
+
*/
|
|
121
|
+
export function collectTypeDirectoryDisagreements(entries, readFile = realReadFile) {
|
|
122
|
+
const disagreements = [];
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
const declared = declaredTypeForPath(entry.filePath);
|
|
125
|
+
if (!declared || declared.type === entry.type)
|
|
126
|
+
continue;
|
|
127
|
+
const content = readFile(entry.filePath);
|
|
128
|
+
const explanation = content === undefined ? undefined : explainOverride(entry.type, content);
|
|
129
|
+
disagreements.push({
|
|
130
|
+
path: entry.filePath,
|
|
131
|
+
resolved: entry.type,
|
|
132
|
+
expected: declared.type,
|
|
133
|
+
winner: explanation?.winner ?? "unknown",
|
|
134
|
+
knownGoodOverride: explanation?.knownGoodOverride ?? false,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return disagreements.sort((a, b) => a.path.localeCompare(b.path));
|
|
138
|
+
}
|
|
139
|
+
const MAX_DETAIL_LINES = 10;
|
|
140
|
+
/**
|
|
141
|
+
* Build the `type-directory-disagreement` advisory, or `undefined` when
|
|
142
|
+
* every indexed entry agrees with its directory. Always `status: "warn"`
|
|
143
|
+
* (never `"fail"`) — a deliberate override is still a disagreement worth
|
|
144
|
+
* seeing, just not a gate.
|
|
145
|
+
*/
|
|
146
|
+
export function buildTypeDirectoryAdvisory(entries, readFile = realReadFile, displayPath = (p) => p) {
|
|
147
|
+
const disagreements = collectTypeDirectoryDisagreements(entries, readFile);
|
|
148
|
+
if (disagreements.length === 0)
|
|
149
|
+
return undefined;
|
|
150
|
+
const lines = disagreements.slice(0, MAX_DETAIL_LINES).map((d) => {
|
|
151
|
+
const note = d.knownGoodOverride ? " (known-good override)" : "";
|
|
152
|
+
return `${displayPath(d.path)} resolved=${d.resolved} expected=${d.expected} winner=${d.winner}${note}`;
|
|
153
|
+
});
|
|
154
|
+
if (disagreements.length > MAX_DETAIL_LINES) {
|
|
155
|
+
lines.push(`+${disagreements.length - MAX_DETAIL_LINES} more`);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
name: "type-directory-disagreement",
|
|
159
|
+
kind: "deterministic",
|
|
160
|
+
status: "warn",
|
|
161
|
+
confidence: "high",
|
|
162
|
+
message: `${disagreements.length} indexed asset(s) have a resolved type that disagrees with the type their directory declares: ${lines.join("; ")}`,
|
|
163
|
+
evidence: {
|
|
164
|
+
disagreements: disagreements.map((d) => ({ ...d, path: displayPath(d.path) })),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
package/dist/commands/health.js
CHANGED
|
@@ -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
|
|
231
|
-
* improve advisories, the `stash-git-exposure` probe,
|
|
232
|
-
*
|
|
233
|
-
* the returned array. A probe/filesystem
|
|
234
|
-
* abort the health report — each group
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
238
|
-
|
|
239
|
-
|
|
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(/</g, "<")
|
|
117
|
+
.replace(/>/g, ">")
|
|
118
|
+
.replace(/"/g, '"')
|
|
119
|
+
.replace(/'/g, "'")
|
|
120
|
+
.replace(/&/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
|
-
|
|
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
|
};
|
package/dist/registry/semver.js
CHANGED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-cli",
|
|
3
|
-
"version": "0.9.2-alpha.
|
|
3
|
+
"version": "0.9.2-alpha.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
|
|
6
6
|
"keywords": [
|