akm-cli 0.9.18 → 0.9.19-alpha.2
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 +156 -0
- package/STABILITY.md +2 -1
- package/dist/assets/hints/cli-hints-full.md +4 -2
- package/dist/assets/hints/cli-hints-short.md +5 -3
- package/dist/assets/prompts/reflect-feedback-framing.md +1 -1
- package/dist/commands/feedback-cli.js +1 -1
- package/dist/commands/improve/consolidate/coverage.js +132 -0
- package/dist/commands/improve/consolidate/pair-pass.js +30 -20
- package/dist/commands/improve/consolidate.js +43 -10
- package/dist/commands/improve/distill.js +7 -3
- package/dist/commands/improve/eligibility.js +39 -9
- package/dist/commands/improve/improve-cli.js +9 -6
- package/dist/commands/improve/improve.js +13 -4
- package/dist/commands/improve/ledger.js +2 -2
- package/dist/commands/improve/loop-stages.js +2 -0
- package/dist/commands/improve/preparation.js +1 -1
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/improve/stage.js +61 -9
- package/dist/commands/proposal/diff-format.js +21 -0
- package/dist/commands/proposal/proposal-cli.js +48 -10
- package/dist/commands/proposal/proposal-types.js +11 -0
- package/dist/commands/proposal/proposal.js +60 -5
- package/dist/commands/proposal/repository.js +245 -17
- package/dist/commands/read/knowledge.js +13 -11
- package/dist/commands/read/remember-cli.js +7 -3
- package/dist/commands/sources/source-clone.js +1 -1
- package/dist/commands/tasks/tasks-cli.js +1 -1
- package/dist/commands/tasks/tasks.js +10 -3
- package/dist/core/mutation-target.js +8 -3
- package/dist/core/write-source.js +3 -2
- package/dist/indexer/usage/usage-events.js +2 -1
- package/dist/output/shapes/helpers.js +7 -0
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/shapes/proposal/reopen.js +14 -0
- package/dist/output/shapes.js +2 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/proposal/proposal.js +3 -1
- package/dist/output/text/proposal-format.js +87 -32
- package/dist/scripts/akm-migrate-node.js +79 -23
- package/dist/scripts/akm-migrate.js +79 -23
- package/dist/storage/repositories/improve-ledger-repository.js +65 -6
- package/dist/storage/repositories/index-vec-repository.js +13 -8
- package/dist/storage/repositories/proposals-repository.js +23 -0
- package/dist/tasks/run/load-task.js +5 -1
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.19.md +137 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/migration/v0.8-to-v0.9.md +5 -1
- package/docs/reference/cli.md +190 -28
- package/docs/reference/configuration.md +9 -8
- package/docs/reference/data-and-telemetry.md +19 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,162 @@ 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.19-alpha.2] - 2026-09-30
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Distill's grounding check vetoes only a score of 1; a 2 goes to a person.**
|
|
14
|
+
0.9.19-alpha.1 made a grounding score of 2 or less `quality_rejected`. A
|
|
15
|
+
calibration of the lesson judge on a local llama.cpp model (34 cases, 5
|
|
16
|
+
passes, 199 calls) scored all 4 off-subject lessons grounding 1 in nearly
|
|
17
|
+
every pass (one scored 2 in 4 of 5 full passes and 1 otherwise), and its only
|
|
18
|
+
false vetoes, 2 of 120 legitimate judgments, were one on-subject lesson that
|
|
19
|
+
adds advice beyond its source and scored 2. Temperature 0 did not make the
|
|
20
|
+
judge repeatable on that server either: scores moved by up to a point between
|
|
21
|
+
passes. A grounding score of 1 is therefore still `quality_rejected` (an
|
|
22
|
+
`improve_ledger` row and a `distill_invoked` event, no proposal, reason
|
|
23
|
+
`Off-subject for its source (grounding 1/5): …`), and a 2 is now
|
|
24
|
+
`review_needed`, a pending proposal for a person with the reason
|
|
25
|
+
`Borderline on grounding (2/5), routed to review: …`, even when the mean of
|
|
26
|
+
novelty and non-redundancy alone would pass it. A mean that alone rejects the
|
|
27
|
+
lesson stays `quality_rejected`, and a grounding score of 3 to 5 is
|
|
28
|
+
unchanged.
|
|
29
|
+
|
|
30
|
+
## [0.9.19-alpha.1] - 2026-09-30
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **`akm proposal reopen <id...> [--reason <text>]` (#997).** A rejection was
|
|
35
|
+
final: nothing undid it, and a rejected `consolidate-pair` retire proposal
|
|
36
|
+
also kept the pair pass from ever proposing that retirement again while both
|
|
37
|
+
documents were unchanged. Reopen moves rejected proposals back to `pending`,
|
|
38
|
+
keeping the rejection (and the gate verdict that came with it) in the
|
|
39
|
+
proposal's new `reviewHistory`, which `proposal show` prints; the verdict
|
|
40
|
+
itself is cleared so the drain sees the proposal as undecided, except a
|
|
41
|
+
`deferred` one (the quality gate's hand-off to a person), which stays. It is
|
|
42
|
+
refused unless the proposal is `rejected` and `accept` would not refuse it as
|
|
43
|
+
stale (an update's target unchanged; a create's target still absent; a retire
|
|
44
|
+
proposal's successor present and both documents' body hashes as recorded),
|
|
45
|
+
and a retire proposal is refused while another pending retire proposal
|
|
46
|
+
involves either of its documents. Several ids are all-or-nothing. The pair pass
|
|
47
|
+
follows the status: a reopened proposal is no longer a settled pair and,
|
|
48
|
+
pending, is not minted twice. Its `improve_ledger` row is reset (a retire
|
|
49
|
+
proposal's rejection row is dropped, any other goes back to `proposed`), the
|
|
50
|
+
age that retention expiry and `--older-than` (bulk accept/reject, `drain`)
|
|
51
|
+
see restarts at the reopen, so a scheduled sweep does not take a proposal a
|
|
52
|
+
person just put back, and a `proposal_reopened` event is appended.
|
|
53
|
+
`akm proposal reject`'s confirmation prompt no longer says a rejection
|
|
54
|
+
cannot be undone.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **A tool failure recorded with `akm feedback` no longer becomes a lesson
|
|
59
|
+
about the error or a `TODO` placeholder in a memory (#999).** Agents
|
|
60
|
+
recorded `akm show` failing on a memory with a `.derived.md` child (fixed in
|
|
61
|
+
0.9.17) as negative feedback, and distill and reflect read it as evidence
|
|
62
|
+
about the memory's content. On one bundle, 9 such events on 8 memories
|
|
63
|
+
produced 4 distill lessons about "duplicate physical owners" for memories on
|
|
64
|
+
unrelated subjects (one auto-accepted and live), and a reflect proposal,
|
|
65
|
+
also auto-accepted, that added a `TODO: verify physical owner` section to a
|
|
66
|
+
memory, on which a fifth lesson was then built. The shipped hints had told
|
|
67
|
+
agents to record `--negative` "when it fails"; they, and the help for
|
|
68
|
+
`akm feedback --reason`, now say a failed akm command is not feedback on the
|
|
69
|
+
asset. Reflect's feedback caveat no longer offers a `TODO: verify …`
|
|
70
|
+
placeholder: when feedback asks for information the asset lacks, it says
|
|
71
|
+
only to leave the section unchanged. The distill quality judge now also
|
|
72
|
+
scores **grounding**, whether the lesson is about what its source is about
|
|
73
|
+
(1–2 only for a different subject; a lesson that corrects its source from
|
|
74
|
+
feedback is not off-subject), and a grounding score of 2 or less is
|
|
75
|
+
`quality_rejected` (an `improve_ledger` row and a `distill_invoked` event, no
|
|
76
|
+
proposal) whatever the mean of novelty and non-redundancy is. Such a lesson
|
|
77
|
+
reads as novel and non-redundant, so it used to pass or, in the review band,
|
|
78
|
+
be minted as a pending `review_needed` proposal. The judge also reads the
|
|
79
|
+
same slice of the source the lesson was generated from (its body without
|
|
80
|
+
frontmatter, first 3000 characters) instead of the raw file's first 2000. A
|
|
81
|
+
lesson that contradicts its source still reaches a human through the
|
|
82
|
+
optional fidelity check (`processes.distill.fidelityCheck.enabled`, off by
|
|
83
|
+
default), and every other `review_needed` reason is unchanged. `TODO:`
|
|
84
|
+
lines already in a memory are not removed.
|
|
85
|
+
- **Consolidation stops re-proposing memories that `knowledge/` already
|
|
86
|
+
covers (#998).** The promote pass copied a memory into a new `knowledge/`
|
|
87
|
+
proposal with no notion of what `knowledge/` already held: the model never
|
|
88
|
+
sees it, the mint-time checks only caught the same slug or a byte-identical
|
|
89
|
+
body, and an accepted promotion's memory was eligible again at once (a
|
|
90
|
+
rejected one after 7 days). On one bundle 88% of a run's proposals came from
|
|
91
|
+
memories promoted before, one of them 14 times, and 215 of 224 rejections
|
|
92
|
+
read "covered by an existing knowledge doc". Two changes, no new setting:
|
|
93
|
+
before queuing a promotion, consolidate now compares the memory with the 20
|
|
94
|
+
`knowledge/` docs in its bundle nearest to it by stored vector (the lookup
|
|
95
|
+
the pair pass uses) and skips it, with skip reason
|
|
96
|
+
`dedup_covered_by_knowledge`, when one of them holds at least half of the
|
|
97
|
+
memory's distinct 5-word shingles (measured against every knowledge doc,
|
|
98
|
+
that share was at least 0.5 for 122 of the 224 rejected proposals and for
|
|
99
|
+
none of the 103 accepted ones; a covering doc past the 20 nearest goes
|
|
100
|
+
unseen); and a memory whose promotion was accepted or rejected is offered
|
|
101
|
+
again only when its body changes, the same content-driven rule the pair pass
|
|
102
|
+
uses, instead of at once or after 7 days. A promotion decided by an older
|
|
103
|
+
release recorded no body hash and keeps its old windows. With no stored
|
|
104
|
+
vector (semantic search off) the coverage check does nothing.
|
|
105
|
+
- **`akm improve` no longer files one bundle's assets into another (#1000).**
|
|
106
|
+
Candidate selection admitted assets from every writable bundle, but every
|
|
107
|
+
proposal is filed in the run's write target and reflect reads each asset
|
|
108
|
+
from the bundle that owns it. An asset owned by another bundle therefore
|
|
109
|
+
came back as a `create` fork in the write target, or as an `update` of the
|
|
110
|
+
write target's own copy built from the other bundle's copy. A run now plans
|
|
111
|
+
only the bundle it writes to (`--bundle`, else `defaultWriteTarget`, else
|
|
112
|
+
the working bundle), and a bare ref scope (`akm improve skills/x`) resolves
|
|
113
|
+
inside that bundle. Distill's memory-to-knowledge promotion likewise merges
|
|
114
|
+
only with a doc that already exists in the write target. As a second line of
|
|
115
|
+
defence, `createProposal` refuses a rewrite whose `itemRef` names an asset
|
|
116
|
+
owned by a different configured bundle than the queue's. **Narrowed
|
|
117
|
+
behaviour:** a run no longer picks up assets from your other writable
|
|
118
|
+
bundles (it used to read them and queue the result in its own write target),
|
|
119
|
+
so a scheduled `akm improve` now covers only its write target: add one
|
|
120
|
+
`akm improve --bundle <name>` run per other bundle you want improved.
|
|
121
|
+
`akm improve <ref>` for an asset that lives only in another bundle now fails
|
|
122
|
+
with a not-found error whose hint names the remedy (`--bundle team`, or
|
|
123
|
+
`akm improve team//skills/x`). Proposals the old behaviour already queued
|
|
124
|
+
stay in the queue; review them with `akm proposal list`. `--bundle`'s help
|
|
125
|
+
text now says it selects the bundle a run improves and writes to.
|
|
126
|
+
- **`akm improve --dry-run`/`--plan` previews the bundle a live run improves.**
|
|
127
|
+
With no `--bundle` and no `defaultWriteTarget`, a live run starts from
|
|
128
|
+
`AKM_BUNDLE_DIR` before `defaultBundle`, but a dry run read `defaultBundle`
|
|
129
|
+
only, so the two could plan different bundles. The preview now resolves the
|
|
130
|
+
working bundle the same way.
|
|
131
|
+
- **`akm proposal diff` shows a retire proposal as a retirement (#997).** It
|
|
132
|
+
rendered the retired file as replaced by one blank line (`----`, a lone `+`,
|
|
133
|
+
then every other line as a removal, under an `(update: <ref>)` header) and
|
|
134
|
+
said nothing about the retirement, so one reviewer rejected all 65 of a
|
|
135
|
+
bundle's `consolidate-pair` proposals as "would destroy content". The diff
|
|
136
|
+
now lists only the removed lines, under a `(retire: <retired> -> <successor>)`
|
|
137
|
+
header and `+++ /dev/null (retired: archived; successor <ref>)`, and its JSON
|
|
138
|
+
result gains `op: "delete"`, a `retirement` block under the keys `proposal
|
|
139
|
+
show` uses (`retiredRef`, `successorRef`, `judgeLabel`, `judgeReason`,
|
|
140
|
+
`cosine`, and `continuityRisk` when the pair was flagged, which the text
|
|
141
|
+
output prints as well) and a `note` that accepting archives the file under
|
|
142
|
+
`.akm/memory-cleanup/archive/` and `akm proposal revert` restores it
|
|
143
|
+
byte-exactly. The new fields are additive and appear on retire proposals
|
|
144
|
+
only. `akm proposal show --detail full` also stops ending a retire proposal
|
|
145
|
+
with a bare `payload:` heading over nothing.
|
|
146
|
+
- **Errors name the flag the command takes, not the retired `--target`
|
|
147
|
+
(`improve`, `remember`, `clone`, `task`, `proposal --queue`).** A `--bundle`
|
|
148
|
+
that names no configured bundle, names a read-only one, or differs from a
|
|
149
|
+
bundle-qualified ref (`akm improve team//skills/x --bundle stash`) failed with
|
|
150
|
+
"--target must reference a source name", "or pass --target to a different
|
|
151
|
+
source" or "conflicts with --target". `akm improve`, `remember`, `clone` and
|
|
152
|
+
every `task` verb reject `--target` (renamed `--bundle` in 0.9), and
|
|
153
|
+
`proposal --queue` takes `--queue`, so each message sent the user to a flag
|
|
154
|
+
that does not work. They now name the flag the command takes, and the
|
|
155
|
+
remedy in `akm remember --supersedes` says "re-run with --bundle" instead of
|
|
156
|
+
"--target". A bundle that came from a ref inside a task (`ghost//workflows/x`)
|
|
157
|
+
is described as such rather than blamed on a flag. The commands that really
|
|
158
|
+
take `--target` (`import`, `env`, `secret`, `proposal accept`) are unchanged.
|
|
159
|
+
Separately, the help for `akm improve --skip-if-locked` said a lock collision
|
|
160
|
+
without the flag exits 78. It exits 75 (`IMPROVE_LOCK_HELD`), as the CLI
|
|
161
|
+
reference says. The `--limit` help says "highest salience first" (it said
|
|
162
|
+
utility), and the `task add --command` help example uses
|
|
163
|
+
`--strategy reflect-distill` (the `frequent` strategy no longer exists).
|
|
164
|
+
|
|
9
165
|
## [0.9.18] - 2026-09-29
|
|
10
166
|
|
|
11
167
|
### Fixed
|
package/STABILITY.md
CHANGED
|
@@ -89,6 +89,7 @@ enumeration of the whole `proposal` noun group.
|
|
|
89
89
|
| `akm proposal diff` | Evolving | |
|
|
90
90
|
| `akm proposal accept` | Evolving | |
|
|
91
91
|
| `akm proposal reject` | Evolving | |
|
|
92
|
+
| `akm proposal reopen` | Evolving | New in 0.9.19; undoes a rejection. |
|
|
92
93
|
| `akm proposal revert` | Evolving | |
|
|
93
94
|
| `akm proposal drain` | Evolving | |
|
|
94
95
|
| `akm proposal extract` | Evolving | Former top-level `akm extract`. |
|
|
@@ -247,7 +248,7 @@ proposal-queue shape may shift. Breaking changes will be flagged in the
|
|
|
247
248
|
CHANGELOG with a migration note.
|
|
248
249
|
|
|
249
250
|
- **Improvement loop** — `akm improve` and the proposal noun group
|
|
250
|
-
`akm proposal {extract,new,list,show,diff,accept,reject,revert,drain}`
|
|
251
|
+
`akm proposal {extract,new,list,show,diff,accept,reject,reopen,revert,drain}`
|
|
251
252
|
(`extract` and `new` are the former top-level `akm extract`/`akm propose`,
|
|
252
253
|
moved under `proposal` in 0.9.0). Output JSON keys
|
|
253
254
|
are stable; CLI flags (`--strategy`, `--task`, `--generator`) may add
|
|
@@ -100,8 +100,9 @@ akm feedback memories/deployment-notes --positive # Works for memories too
|
|
|
100
100
|
akm feedback env/prod --positive # Records env feedback without surfacing values
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
Use `akm feedback` whenever an asset materially helps or
|
|
104
|
-
ranking can learn from actual usage.
|
|
103
|
+
Use `akm feedback` whenever an asset's content materially helps, or proves wrong,
|
|
104
|
+
stale or unhelpful, so future search ranking can learn from actual usage. An akm
|
|
105
|
+
command that fails says nothing about the asset; don't record it as feedback.
|
|
105
106
|
|
|
106
107
|
## LLM Wiki bundles
|
|
107
108
|
|
|
@@ -340,6 +341,7 @@ akm proposal accept 7c115132 # Accept by UUID prefix
|
|
|
340
341
|
akm proposal accept <id> --target team-bundle # Accept to a named writable bundle source
|
|
341
342
|
akm proposal reject skills/my-skill --reason "not ready" # Reject by asset ref
|
|
342
343
|
akm proposal reject <id> --reason "..." # Archive with a reason
|
|
344
|
+
akm proposal reopen <id> --reason "..." # Undo a rejection: back to pending (refused if the target changed)
|
|
343
345
|
akm proposal revert <id> # Restore the pre-promotion content
|
|
344
346
|
akm proposal new <type> <name> --task "..." # Agent-author a NEW asset as a proposal
|
|
345
347
|
akm proposal extract --auto # Mine native session files into proposals
|
|
@@ -8,7 +8,7 @@ For any task, follow this loop:
|
|
|
8
8
|
1. `akm curate "<task>"` — find the best matching asset
|
|
9
9
|
2. `akm show <ref>` — read the schema (field names and structure)
|
|
10
10
|
3. Edit the workspace file using schema field names + task-specific values from your README
|
|
11
|
-
4. `akm feedback <ref> --positive` — record
|
|
11
|
+
4. `akm feedback <ref> --positive` — record that the asset helped; use `--negative --reason "..."` when its content was wrong, stale or unhelpful. A failed akm command (e.g. `akm show` erroring) is not feedback on the asset — don't record it.
|
|
12
12
|
|
|
13
13
|
For workflow tasks:
|
|
14
14
|
1. `akm show workflows/<name>` — inspect the procedure before executing it
|
|
@@ -63,8 +63,10 @@ akm search "<query>" --from registry # Search all registries (registry
|
|
|
63
63
|
| secret | A single sensitive value for AUTHENTICATION (token, key, cert); name only. Inject with `akm secret run <ref> <VAR> -- <cmd>`. |
|
|
64
64
|
| lesson | A distilled feedback lesson: `content` plus `action` (rendered from the `when_to_use` frontmatter). Read both before applying a related skill. Generated by the improve pipeline and promoted through the proposal queue. |
|
|
65
65
|
|
|
66
|
-
When an asset meaningfully helps or
|
|
67
|
-
future search ranking can learn from real
|
|
66
|
+
When an asset's content meaningfully helps, or proves wrong, stale or unhelpful,
|
|
67
|
+
record that with `akm feedback` so future search ranking can learn from real
|
|
68
|
+
usage. An akm command that fails says nothing about the asset; don't record it
|
|
69
|
+
as feedback.
|
|
68
70
|
|
|
69
71
|
## Error Shapes and Exit Codes
|
|
70
72
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Feedback describes what a reader found missing or wrong. It is a signal to investigate, not a fact to insert. Do not add claims, numbers, dates, paths, ports, or incidents that are not already present in the asset content. If feedback asks for information the asset lacks,
|
|
1
|
+
Feedback describes what a reader found missing or wrong. It is a signal to investigate, not a fact to insert. Do not add claims, numbers, dates, paths, ports, or incidents that are not already present in the asset content. If feedback asks for information the asset lacks, leave the section unchanged.
|
|
@@ -208,7 +208,7 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
208
208
|
},
|
|
209
209
|
reason: {
|
|
210
210
|
type: "string",
|
|
211
|
-
description: "
|
|
211
|
+
description: "What was wrong with (or right about) the asset's content (required for negative feedback by default; used by distillation). Not for akm command errors.",
|
|
212
212
|
},
|
|
213
213
|
"failure-mode": {
|
|
214
214
|
type: "string",
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
* The promote pass's coverage gate (#998): before a memory becomes a
|
|
6
|
+
* `knowledge/` proposal, ask whether `knowledge/` already says it.
|
|
7
|
+
*
|
|
8
|
+
* The rule: a memory is covered when at least {@link COVERAGE_MIN_CONTAINMENT}
|
|
9
|
+
* (half) of its distinct {@link COVERAGE_SHINGLE_WORDS}-word shingles appear in
|
|
10
|
+
* one of the knowledge docs nearest to it. It is a containment of the MEMORY in
|
|
11
|
+
* the doc, not a similarity: a long guide that quotes the memory covers it; a
|
|
12
|
+
* memory that quotes a short doc and adds claims of its own does not.
|
|
13
|
+
*
|
|
14
|
+
* Why this rule and this cut. The model never sees `knowledge/`, and the
|
|
15
|
+
* mint-time checks before this one only catch the same slug or the same whole
|
|
16
|
+
* body, so one edit or paraphrase defeats them. The evidence is #998's, from
|
|
17
|
+
* the owner's bundle (run `consolidate-1790666282516`: 327 decided proposals,
|
|
18
|
+
* 103 accepted, 224 rejected, 215 of those as "duplicate of / covered by /
|
|
19
|
+
* overlaps" an existing knowledge doc): the share of a proposal's distinct
|
|
20
|
+
* 5-word shingles found in the best OTHER knowledge doc was >= 0.5 for 122 of
|
|
21
|
+
* the 224 rejected proposals and for none of the 103 accepted ones. 0.5 is the
|
|
22
|
+
* cut that sample supports: no accepted promotion would have been skipped. That
|
|
23
|
+
* sample compared each proposal with EVERY other knowledge doc, so 122 of 224
|
|
24
|
+
* (up to about 54%) is what the rule can take out of review at most, not what
|
|
25
|
+
* this gate achieves: it reads only the {@link PAIR_NEIGHBOR_FETCH_K} nearest
|
|
26
|
+
* knowledge docs (below), and a covering doc that ranks lower goes unseen. The
|
|
27
|
+
* recall over that candidate set is unmeasured. The rest of the rejected
|
|
28
|
+
* proposals (paraphrases, partial overlaps) still reach review: the next cut
|
|
29
|
+
* measured, 0.2 (169 of 224 rejected), would also have skipped 2 of the 103
|
|
30
|
+
* accepted ones, and no cosine cut was measured at all. A wrong skip is a
|
|
31
|
+
* promotion nobody gets to review.
|
|
32
|
+
*
|
|
33
|
+
* Candidates are the memory's {@link PAIR_NEIGHBOR_FETCH_K} nearest knowledge
|
|
34
|
+
* docs in its bundle by stored vector (`getNeighborsByEntryId`, the lookup the
|
|
35
|
+
* pair pass runs, scoped to the bundle's knowledge entries), never a scan of
|
|
36
|
+
* `knowledge/`. There is no similarity floor, only a rank: a guide that quotes
|
|
37
|
+
* the memory is found however far it sits from it by vector, as long as fewer
|
|
38
|
+
* than that many other knowledge docs in the bundle are nearer. With no stored
|
|
39
|
+
* vector (semantic search off, the memory not indexed yet, no index at all)
|
|
40
|
+
* there are no candidates and the gate does nothing: the exact-slug and
|
|
41
|
+
* whole-body checks still run, and nothing throws.
|
|
42
|
+
*/
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
import { closeDatabase, openExistingDatabase } from "../../../storage/repositories/index-connection.js";
|
|
45
|
+
import { getEntryById, getEntryIdByFilePath } from "../../../storage/repositories/index-entries-repository.js";
|
|
46
|
+
import { getNeighborsByEntryId } from "../../../storage/repositories/index-vec-repository.js";
|
|
47
|
+
import { stripFrontmatterBody } from "../content-hash.js";
|
|
48
|
+
import { PAIR_NEIGHBOR_FETCH_K } from "./pair-pass.js";
|
|
49
|
+
/** Words per shingle. */
|
|
50
|
+
export const COVERAGE_SHINGLE_WORDS = 5;
|
|
51
|
+
/** Share of a memory's distinct shingles one knowledge doc must hold for the memory to count as covered. */
|
|
52
|
+
export const COVERAGE_MIN_CONTAINMENT = 0.5;
|
|
53
|
+
const WORD = /[\p{L}\p{N}]+/gu;
|
|
54
|
+
/** The distinct lower-cased word n-grams of `text`; empty when it has fewer than {@link COVERAGE_SHINGLE_WORDS} words. */
|
|
55
|
+
export function wordShingles(text) {
|
|
56
|
+
const words = text.toLowerCase().match(WORD) ?? [];
|
|
57
|
+
const shingles = new Set();
|
|
58
|
+
for (let i = 0; i + COVERAGE_SHINGLE_WORDS <= words.length; i++) {
|
|
59
|
+
shingles.add(words.slice(i, i + COVERAGE_SHINGLE_WORDS).join(" "));
|
|
60
|
+
}
|
|
61
|
+
return shingles;
|
|
62
|
+
}
|
|
63
|
+
/** The share (0..1) of `memory`'s shingles that also occur in `doc`; 0 when the memory has none. */
|
|
64
|
+
export function shingleContainment(memory, doc) {
|
|
65
|
+
if (memory.size === 0)
|
|
66
|
+
return 0;
|
|
67
|
+
const docShingles = wordShingles(doc);
|
|
68
|
+
let shared = 0;
|
|
69
|
+
for (const shingle of memory)
|
|
70
|
+
if (docShingles.has(shingle))
|
|
71
|
+
shared++;
|
|
72
|
+
return shared / memory.size;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The best-covering knowledge doc among the {@link PAIR_NEIGHBOR_FETCH_K}
|
|
76
|
+
* knowledge docs in `bundleId` nearest to the memory. `filePath` is the
|
|
77
|
+
* memory's indexed file; a memory the index does not know has no stored vector
|
|
78
|
+
* and so no candidates.
|
|
79
|
+
*/
|
|
80
|
+
export function findCoveringKnowledge(db, bundleId, filePath, body) {
|
|
81
|
+
const shingles = wordShingles(body);
|
|
82
|
+
if (shingles.size === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const entryId = getEntryIdByFilePath(db, filePath);
|
|
85
|
+
if (entryId === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
let best;
|
|
88
|
+
for (const hit of getNeighborsByEntryId(db, entryId, PAIR_NEIGHBOR_FETCH_K, { type: "knowledge", bundleId })) {
|
|
89
|
+
const neighbour = getEntryById(db, hit.id);
|
|
90
|
+
if (!neighbour)
|
|
91
|
+
continue;
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = fs.readFileSync(neighbour.filePath, "utf8");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue; // the index outlived the file
|
|
98
|
+
}
|
|
99
|
+
const containment = shingleContainment(shingles, stripFrontmatterBody(raw));
|
|
100
|
+
if (containment >= COVERAGE_MIN_CONTAINMENT && (best === undefined || containment > best.containment)) {
|
|
101
|
+
best = { ref: neighbour.conceptId, containment };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return best;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The gate for one run, holding its own read handle on `index.db` for the
|
|
108
|
+
* promotions that run emits; `undefined` when there is no bundle or no index
|
|
109
|
+
* to look in. A lookup that throws counts as "not known to be covered".
|
|
110
|
+
*/
|
|
111
|
+
export function openKnowledgeCoverage(bundleId) {
|
|
112
|
+
if (!bundleId)
|
|
113
|
+
return undefined;
|
|
114
|
+
let db;
|
|
115
|
+
try {
|
|
116
|
+
db = openExistingDatabase();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
find: (filePath, body) => {
|
|
123
|
+
try {
|
|
124
|
+
return findCoveringKnowledge(db, bundleId, filePath, body);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
close: () => closeDatabase(db),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -273,6 +273,35 @@ function pairKey(a, b) {
|
|
|
273
273
|
function rejectedPairKey(retiredRef, successorRef, retiredHash, successorHash) {
|
|
274
274
|
return [retiredRef, successorRef, retiredHash, successorHash].join("\u0000");
|
|
275
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Item 0 / S1: every rejected OR reverted consolidate-pair retirement on
|
|
278
|
+
* record, as {@link rejectedPairKey}s — read once per run, the same shape as
|
|
279
|
+
* `pendingRetireRefs` in {@link runConsolidatePairPass}. `reverted` is
|
|
280
|
+
* included alongside `rejected`: a person undoing an accept via `akm proposal
|
|
281
|
+
* revert` is the same "no, not this" signal as a reject — without it, the next
|
|
282
|
+
* run would re-mint the identical retirement, and a bulk accept could
|
|
283
|
+
* re-apply a decision the person just undid. The keys follow each proposal's
|
|
284
|
+
* CURRENT status, so one `akm proposal reopen` put back to `pending` (#997) is
|
|
285
|
+
* no longer among them — and, pending, blocks its pair from a second mint.
|
|
286
|
+
*
|
|
287
|
+
* @internal exported for unit tests.
|
|
288
|
+
*/
|
|
289
|
+
export function loadRejectedPairKeys(stashDir, proposalsCtx) {
|
|
290
|
+
const keys = new Set();
|
|
291
|
+
try {
|
|
292
|
+
for (const status of ["rejected", "reverted"]) {
|
|
293
|
+
for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, proposalsCtx)) {
|
|
294
|
+
if (!isRetireProposal(p) || !p.retirement)
|
|
295
|
+
continue;
|
|
296
|
+
keys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Best-effort de-dup only; a failed read never blocks judging.
|
|
302
|
+
}
|
|
303
|
+
return keys;
|
|
304
|
+
}
|
|
276
305
|
/**
|
|
277
306
|
* Initiators (plan §5.2 step 1, S1 post-review): the pool, in the retrieval
|
|
278
307
|
* scope, and content-eligible — no prior `consolidate-pair` ledger row, or a
|
|
@@ -647,26 +676,7 @@ seams = {}) {
|
|
|
647
676
|
// Best-effort de-dup only; a failed read never blocks judging.
|
|
648
677
|
}
|
|
649
678
|
const isPendingBlocked = (c) => pendingRetireRefs.has(stripBundle(c.initiator.ref)) || pendingRetireRefs.has(stripBundle(c.other.ref));
|
|
650
|
-
|
|
651
|
-
// record, keyed by its exact ref pair and both content hashes — read once
|
|
652
|
-
// per run, the same shape as pendingRetireRefs above. `reverted` is
|
|
653
|
-
// included alongside `rejected`: a person undoing an accept via `akm
|
|
654
|
-
// proposal revert` is the same "no, not this" signal as a reject — without
|
|
655
|
-
// it, the next run would re-mint the identical retirement, and a bulk
|
|
656
|
-
// accept could re-apply a decision the person just undid.
|
|
657
|
-
const rejectedPairKeys = new Set();
|
|
658
|
-
try {
|
|
659
|
-
for (const status of ["rejected", "reverted"]) {
|
|
660
|
-
for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, opts.proposalsCtx)) {
|
|
661
|
-
if (!isRetireProposal(p) || !p.retirement)
|
|
662
|
-
continue;
|
|
663
|
-
rejectedPairKeys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
catch {
|
|
668
|
-
// Best-effort de-dup only; a failed read never blocks judging.
|
|
669
|
-
}
|
|
679
|
+
const rejectedPairKeys = loadRejectedPairKeys(stashDir, opts.proposalsCtx);
|
|
670
680
|
// Blocker 2: admit WHOLE initiators under MAX_PAIRS_PER_RUN, never
|
|
671
681
|
// individual pairs — the old flat "top 300 candidates by cosine" cap let a
|
|
672
682
|
// pending-blocked pair spend a budget slot doing nothing, and left
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* promoted. Promotion emits a reviewable proposal and never touches the
|
|
8
8
|
* memory directly; accepting it later retires the source memory (O1, in
|
|
9
9
|
* `proposal/repository.ts`). Memories the improve ledger judged recently and
|
|
10
|
-
* that have not changed since are not judged again
|
|
10
|
+
* that have not changed since are not judged again, and a memory whose
|
|
11
|
+
* promotion was accepted or rejected waits until its body changes. A memory
|
|
12
|
+
* that a neighbouring knowledge doc already covers is not promoted
|
|
13
|
+
* (`consolidate/coverage.ts`).
|
|
11
14
|
*
|
|
12
15
|
* Accounting invariant (the promote pass only): `processed == promoted +
|
|
13
16
|
* judgedNoAction + Σ(skipReasons) + failedChunkMemories`.
|
|
@@ -41,11 +44,12 @@ import { getAllEntries } from "../../storage/repositories/index-entries-reposito
|
|
|
41
44
|
import { listProposals, listProposalsReadOnly, proposalContent } from "../proposal/repository.js";
|
|
42
45
|
import { hasHotCaptureMode, hasSupersededStatus, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
|
|
43
46
|
import { buildChunkPrompt, computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
|
|
47
|
+
import { openKnowledgeCoverage } from "./consolidate/coverage.js";
|
|
44
48
|
import { runConsolidatePairPass } from "./consolidate/pair-pass.js";
|
|
45
49
|
import { sanitizeMergedContent } from "./consolidate/sanitize.js";
|
|
46
50
|
import { contentHash } from "./content-hash.js";
|
|
47
51
|
import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
|
|
48
|
-
import { isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
|
|
52
|
+
import { isContentDrivenRow, isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
|
|
49
53
|
import { isInRetrievalScope, loadRetrievalScope } from "./retrieval-scope.js";
|
|
50
54
|
import { callStage, mintProposal, noticeSet, stageRunner } from "./stage.js";
|
|
51
55
|
/** A plan op worth acting on. Retired advisory ops (merge/delete/contradict) are dropped, never thrown on. */
|
|
@@ -420,12 +424,26 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
|
|
|
420
424
|
}
|
|
421
425
|
memories = memories.filter((memory) => fs.existsSync(memory.filePath));
|
|
422
426
|
const poolSize = memories.length;
|
|
423
|
-
// A memory judged within its revisit window comes back once it is edited.
|
|
427
|
+
// A memory judged within its revisit window comes back once it is edited. A
|
|
428
|
+
// promotion that was accepted or rejected holds its memory until the body
|
|
429
|
+
// changes, however long that takes (#998).
|
|
424
430
|
const ledger = loadLedgerSnapshot({ proposalsCtx: opts.proposalsCtx, readOnly }, stashDir, ["consolidate"]);
|
|
425
431
|
if (ledger.size > 0) {
|
|
426
432
|
const nowIso = new Date().toISOString();
|
|
427
433
|
memories = memories.filter((memory) => {
|
|
428
434
|
const row = ledger.get(ledgerKey("consolidate", conceptIdFromTypeName("memory", memory.name)));
|
|
435
|
+
if (!row)
|
|
436
|
+
return true;
|
|
437
|
+
if (isContentDrivenRow(row)) {
|
|
438
|
+
let bodyHash;
|
|
439
|
+
try {
|
|
440
|
+
bodyHash = contentHash(fs.readFileSync(memory.filePath, "utf8"), "body");
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
bodyHash = undefined;
|
|
444
|
+
}
|
|
445
|
+
return row.contentHash !== bodyHash;
|
|
446
|
+
}
|
|
429
447
|
let changedAt;
|
|
430
448
|
try {
|
|
431
449
|
changedAt = fs.statSync(memory.filePath).mtime.toISOString();
|
|
@@ -433,7 +451,7 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
|
|
|
433
451
|
catch {
|
|
434
452
|
changedAt = undefined;
|
|
435
453
|
}
|
|
436
|
-
return !
|
|
454
|
+
return !isLedgerBlocked(row, nowIso, changedAt);
|
|
437
455
|
});
|
|
438
456
|
}
|
|
439
457
|
const judgedUnchanged = poolSize - memories.length;
|
|
@@ -651,7 +669,7 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
651
669
|
const { memories, prefilteredAlreadyPromoted } = pool;
|
|
652
670
|
const plural = (n) => `memor${n === 1 ? "y" : "ies"}`;
|
|
653
671
|
if (pool.judgedUnchanged > 0) {
|
|
654
|
-
warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window and unchanged since.`);
|
|
672
|
+
warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window, or already promoted or rejected, and unchanged since.`);
|
|
655
673
|
}
|
|
656
674
|
if (pool.outsideRetrievalScope > 0) {
|
|
657
675
|
warnings.push(`Consolidation: skipped ${pool.outsideRetrievalScope} ${plural(pool.outsideRetrievalScope)} already judged that retrieval has not returned in the last ${USAGE_EVENT_RETENTION_DAYS} days.`);
|
|
@@ -664,8 +682,8 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
664
682
|
// sees .derived memories, flat knowledge and lessons, not just the
|
|
665
683
|
// promote pool above), so it runs regardless of whether the promote pool
|
|
666
684
|
// is empty — every return path below carries its result.
|
|
667
|
-
const
|
|
668
|
-
const pairPass = await runConsolidatePairPass(opts, config, stashDir,
|
|
685
|
+
const bundleId = resolveConsolidationSourceOwner(opts, stashDir)?.bundleId;
|
|
686
|
+
const pairPass = await runConsolidatePairPass(opts, config, stashDir, bundleId, warnings);
|
|
669
687
|
if (memories.length === 0) {
|
|
670
688
|
return makeConsolidateResult({
|
|
671
689
|
dryRun: opts.dryRun ?? false,
|
|
@@ -719,8 +737,16 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
719
737
|
warnings,
|
|
720
738
|
pushSkipReason: (op, ref, reason) => pushSkipReason(acc, op, ref, reason),
|
|
721
739
|
};
|
|
722
|
-
|
|
723
|
-
|
|
740
|
+
const coverage = plan.allOps.length > 0 ? openKnowledgeCoverage(bundleId) : undefined;
|
|
741
|
+
if (coverage)
|
|
742
|
+
ctx.coveringKnowledge = coverage.find;
|
|
743
|
+
try {
|
|
744
|
+
for (const op of plan.allOps)
|
|
745
|
+
await emitPromotionProposal(op, ctx);
|
|
746
|
+
}
|
|
747
|
+
finally {
|
|
748
|
+
coverage?.close();
|
|
749
|
+
}
|
|
724
750
|
// Every other judged memory waits out its revisit window (or its next edit);
|
|
725
751
|
// a promotion that failed to persist is retried next run.
|
|
726
752
|
recordLedgerAttempt({ proposalsCtx: opts.proposalsCtx }, [...acc.judgedRefs]
|
|
@@ -765,7 +791,8 @@ const PROMOTE_BODY_MIN_CHARS = 100;
|
|
|
765
791
|
/**
|
|
766
792
|
* Queue one promotion as a proposal. Refused (with a skip reason) when the
|
|
767
793
|
* memory is unknown, already promoted this run, already pending or present
|
|
768
|
-
* as knowledge (by concept, body hash or slug variant),
|
|
794
|
+
* as knowledge (by concept, body hash or slug variant), already covered by a
|
|
795
|
+
* neighbouring knowledge doc (`coverage.ts`), unreadable, fails
|
|
769
796
|
* sanitization, is superseded, has a body too small to be knowledge, or has
|
|
770
797
|
* no valid description.
|
|
771
798
|
* @internal Exported for promotion-path integration tests.
|
|
@@ -842,6 +869,12 @@ export async function emitPromotionProposal(op, ctx) {
|
|
|
842
869
|
if (sameBody) {
|
|
843
870
|
return skip("dedup_pending_proposal", `Skipping promote: identical body already pending as proposal ${sameBody.id} (ref: ${sameBody.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
|
|
844
871
|
}
|
|
872
|
+
// #998: the copies the two exact checks above cannot see — an earlier
|
|
873
|
+
// promotion that was edited or re-slugged, a doc that quotes the memory.
|
|
874
|
+
const covering = ctx.coveringKnowledge?.(entry.filePath, sourceBody);
|
|
875
|
+
if (covering) {
|
|
876
|
+
return skip("dedup_covered_by_knowledge", `Skipping promote: ${op.ref} → ${knowledgeRef} is already covered by ${covering.ref} (${Math.round(covering.containment * 100)}% of its text appears there).`);
|
|
877
|
+
}
|
|
845
878
|
try {
|
|
846
879
|
const description = (typeof op.description === "string" && op.description.trim()
|
|
847
880
|
? op.description.trim()
|
|
@@ -20,7 +20,7 @@ import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/f
|
|
|
20
20
|
import { stripMarkdownFences } from "../../core/asset/markdown.js";
|
|
21
21
|
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
22
22
|
import { authoringRulesForType } from "../../core/authoring-rules.js";
|
|
23
|
-
import { resolveStashDir } from "../../core/common.js";
|
|
23
|
+
import { isWithin, resolveStashDir } from "../../core/common.js";
|
|
24
24
|
import { getImproveProcessConfig, loadConfig } from "../../core/config/config.js";
|
|
25
25
|
import { UsageError } from "../../core/errors.js";
|
|
26
26
|
import { appendEvent, readEvents } from "../../core/events.js";
|
|
@@ -436,7 +436,9 @@ async function judgeAndQueue(run, out) {
|
|
|
436
436
|
let confidence;
|
|
437
437
|
if (qualityGateEnabled(run)) {
|
|
438
438
|
const similarLessons = await run.similar(content.slice(0, 500), 3);
|
|
439
|
-
|
|
439
|
+
// The judge reads what the generator read: the source body, without its frontmatter (buildDistillPrompt).
|
|
440
|
+
const source = out.source ? parseFrontmatter(out.source).content.trim() : "";
|
|
441
|
+
const verdict = await runLessonQualityJudge(run.config, content, source, run.options.chat, {
|
|
440
442
|
...(similarLessons.length > 0 ? { similarLessons } : {}),
|
|
441
443
|
...(run.runner ? { llmRunner: run.runner } : {}),
|
|
442
444
|
...(run.options.signal ? { signal: run.options.signal } : {}),
|
|
@@ -599,8 +601,10 @@ async function planPromotion(run, feedbackEvents) {
|
|
|
599
601
|
const existingPath = await run.lookup(assessment.knowledgeRef);
|
|
600
602
|
let existing = null;
|
|
601
603
|
try {
|
|
602
|
-
|
|
604
|
+
// The promotion is filed in run.stash, so only that bundle's doc is its destination (#1000).
|
|
605
|
+
if (existingPath && fs.existsSync(existingPath) && isWithin(existingPath, run.stash)) {
|
|
603
606
|
existing = fs.readFileSync(existingPath, "utf8");
|
|
607
|
+
}
|
|
604
608
|
}
|
|
605
609
|
catch {
|
|
606
610
|
existing = null;
|