akm-cli 0.9.17-alpha.8 → 0.9.17
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 +277 -1694
- package/STABILITY.md +9 -8
- package/dist/assets/hints/cli-hints-full.md +6 -7
- package/dist/assets/improve-strategies/catchup.json +0 -3
- package/dist/assets/improve-strategies/consolidate.json +0 -1
- package/dist/assets/improve-strategies/default.json +1 -2
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
- package/dist/assets/improve-strategies/quick.json +1 -2
- package/dist/assets/improve-strategies/reflect-distill.json +1 -2
- package/dist/assets/improve-strategies/thorough.json +0 -3
- package/dist/assets/prompts/consolidate-pair.md +20 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +17 -19
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
- package/dist/assets/templates/html/health.html +3 -5
- package/dist/cli/retired-commands.js +1 -1
- package/dist/cli/unknown-flags.js +24 -1
- package/dist/cli.js +46 -1
- package/dist/commands/health/archive-usage.js +92 -0
- package/dist/commands/health/data-dir-usage.js +25 -13
- package/dist/commands/health/html-report.js +1 -4
- package/dist/commands/health/improve-metrics.js +25 -37
- package/dist/commands/health/md-report.js +1 -6
- package/dist/commands/health/report-view-model.js +4 -14
- package/dist/commands/health/windows.js +0 -1
- package/dist/commands/health.js +13 -0
- package/dist/commands/improve/consolidate/continuity-check.js +137 -0
- package/dist/commands/improve/consolidate/pair-pass.js +791 -0
- package/dist/commands/improve/consolidate.js +38 -63
- package/dist/commands/improve/extract-prompt.js +1 -2
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/improve/improve-strategies.js +23 -5
- package/dist/commands/improve/improve.js +19 -30
- package/dist/commands/improve/ledger.js +3 -2
- package/dist/commands/improve/loop-stages.js +5 -85
- package/dist/commands/improve/memory/memory-belief.js +3 -1
- package/dist/commands/improve/memory/memory-improve.js +262 -11
- package/dist/commands/improve/planner.js +0 -5
- package/dist/commands/improve/preparation.js +20 -135
- package/dist/commands/improve/retrieval-scope.js +19 -4
- package/dist/commands/improve/salience.js +1 -14
- package/dist/commands/improve/stage.js +0 -1
- package/dist/commands/lint/base-linter.js +19 -11
- package/dist/commands/proposal/drain.js +8 -1
- package/dist/commands/proposal/proposal-cli.js +16 -2
- package/dist/commands/proposal/proposal-types.js +7 -0
- package/dist/commands/proposal/proposal.js +37 -6
- package/dist/commands/proposal/repository.js +613 -4
- package/dist/commands/proposal/validators/proposals.js +9 -0
- package/dist/commands/read/knowledge.js +3 -2
- package/dist/commands/read/show.js +0 -14
- package/dist/commands/sources/info.js +122 -18
- package/dist/commands/sources/stash-cli.js +23 -3
- package/dist/core/bundle-rename.js +1 -7
- package/dist/core/config/config-schema.js +8 -1
- package/dist/core/config/config.js +23 -48
- package/dist/core/config/engine-semantics.js +0 -2
- package/dist/core/config/schema/improve-processes.js +17 -42
- package/dist/core/config/schema/index-config.js +5 -25
- package/dist/core/file-change.js +13 -5
- package/dist/core/improve-result.js +22 -6
- package/dist/core/improve-types.js +0 -1
- package/dist/core/loopback.js +7 -12
- package/dist/core/parse.js +13 -16
- package/dist/core/state/migrations.js +15 -0
- package/dist/core/time.js +0 -20
- package/dist/indexer/db/llm-cache.js +2 -2
- package/dist/indexer/ensure-index.js +2 -2
- package/dist/indexer/index-written-assets.js +2 -3
- package/dist/indexer/indexer.js +18 -418
- package/dist/indexer/passes/metadata.js +0 -19
- package/dist/indexer/walk/walker.js +3 -4
- package/dist/llm/client.js +8 -10
- package/dist/llm/embedders/remote.js +1 -2
- package/dist/llm/feature-gate.js +0 -5
- package/dist/output/shapes/helpers.js +20 -4
- package/dist/output/text/command-format.js +9 -8
- package/dist/output/text/proposal-format.js +47 -1
- package/dist/output/text/show-format.js +0 -20
- package/dist/scripts/akm-migrate-node.js +923 -950
- package/dist/scripts/akm-migrate.js +923 -950
- package/dist/setup/steps/connection.js +5 -6
- package/dist/setup/steps/platforms.js +2 -2
- package/dist/sources/providers/git-stash.js +83 -4
- package/dist/storage/repositories/improve-ledger-repository.js +48 -7
- package/dist/storage/repositories/index-connection.js +5 -2
- package/dist/storage/repositories/index-entries-repository.js +4 -7
- package/dist/storage/repositories/index-entry-schema.js +4 -2
- package/dist/storage/repositories/index-llm-cache-repository.js +7 -26
- package/dist/storage/repositories/index-schema.js +55 -104
- package/dist/storage/repositories/proposals-repository.js +61 -0
- package/dist/storage/repositories/salience-repository.js +1 -19
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.17.md +130 -41
- package/docs/migration/release-notes/README.md +7 -0
- package/docs/reference/cli.md +27 -21
- package/docs/reference/configuration.md +21 -12
- package/docs/reference/data-and-telemetry.md +0 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -342
- package/dist/assets/improve-strategies/graph-refresh.json +0 -15
- package/dist/assets/prompts/contradiction-judge.md +0 -33
- package/dist/assets/prompts/graph-extract-system.md +0 -1
- package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
- package/dist/assets/prompts/metadata-enhance-system.md +0 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
- package/dist/indexer/db/graph-db.js +0 -399
- package/dist/indexer/graph/graph-extraction.js +0 -809
- package/dist/indexer/graph/graph-related.js +0 -131
- package/dist/indexer/graph/graph-types.js +0 -4
- package/dist/llm/graph-extract.js +0 -892
- package/dist/llm/metadata-enhance.js +0 -95
package/STABILITY.md
CHANGED
|
@@ -366,8 +366,8 @@ for scripted use.
|
|
|
366
366
|
### `akm improve` autonomy — opt-in in 0.9.0
|
|
367
367
|
|
|
368
368
|
**`akm improve` is review-first by default in 0.9.0.** The command itself is ON
|
|
369
|
-
— its schedules
|
|
370
|
-
|
|
369
|
+
— its schedules and reflect/distill proposals all run — but the lanes that
|
|
370
|
+
mutate assets *without* review require an explicit opt-in:
|
|
371
371
|
|
|
372
372
|
```sh
|
|
373
373
|
akm config set experimental.improveAutonomy true
|
|
@@ -388,9 +388,10 @@ review-first config correctly shows `queue`.
|
|
|
388
388
|
| memory cleanup | Belief-state frontmatter rewrites, archive moves | analyzed but not applied |
|
|
389
389
|
| `triage` `applyMode: "promote"` | Auto-accepts queued proposals into the bundle | downgraded to `queue` — triage still runs, it just does not auto-accept |
|
|
390
390
|
|
|
391
|
-
Consolidation remains enabled with autonomy off
|
|
392
|
-
|
|
393
|
-
proposal
|
|
391
|
+
Consolidation remains enabled with autonomy off: both its passes (promotion,
|
|
392
|
+
and the pair pass's duplicate/subsumed/supersedes judging) only ever emit a
|
|
393
|
+
reviewable proposal, and a pair-pass `retire` proposal is never auto-accepted
|
|
394
|
+
by `triage` `applyMode: "promote"` regardless of this gate.
|
|
394
395
|
|
|
395
396
|
Because the gate is applied before the LLM preflight, a review-first workspace
|
|
396
397
|
also needs fewer engines configured: a strategy whose only model-backed process
|
|
@@ -409,9 +410,9 @@ Autonomy is never inferred: an absent `experimental` section, an absent key, and
|
|
|
409
410
|
an explicit `false` all read as off, so a partially-written or older config is
|
|
410
411
|
review-first rather than accidentally permissive.
|
|
411
412
|
|
|
412
|
-
Reflect, distill, extract candidates, validation, proactive-maintenance
|
|
413
|
-
selection
|
|
414
|
-
|
|
413
|
+
Reflect, distill, extract candidates, validation, and proactive-maintenance
|
|
414
|
+
selection are proposal-only and never write assets directly. Two further
|
|
415
|
+
direct writes are ungated by design: `extract`'s session
|
|
415
416
|
indexing (additive `sessions/**` writes,
|
|
416
417
|
`processes.extract.indexSessions`, default on) and distill's
|
|
417
418
|
encoding-salience frontmatter stamp (metadata only).
|
|
@@ -245,10 +245,9 @@ akm improve --no-push # commit but skip push for this ru
|
|
|
245
245
|
akm improve --sync # force sync even on strategies that disable it
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
-
Strategy sync defaults: `catchup`, `consolidate`, `default`,
|
|
249
|
-
`
|
|
250
|
-
|
|
251
|
-
with `--sync` / `--no-sync` flags.
|
|
248
|
+
Strategy sync defaults: `catchup`, `consolidate`, `default`, `quick`, and
|
|
249
|
+
`thorough` auto-commit + push; `proactive-maintenance` and `reflect-distill`
|
|
250
|
+
skip sync entirely. Override with `--sync` / `--no-sync` flags.
|
|
252
251
|
|
|
253
252
|
The `--writable` flag on `akm bundle add` opts a remote git bundle into push-on-sync:
|
|
254
253
|
|
|
@@ -308,8 +307,8 @@ akm bundle create # Initialize working bund
|
|
|
308
307
|
akm setup # Interactive wizard: bundle + LLM/embedding + agent + registry config
|
|
309
308
|
akm setup --dir ~/custom-bundle # Run the wizard against a custom bundle path
|
|
310
309
|
akm setup --yes # Non-interactive, accepts all defaults
|
|
311
|
-
akm index # Rebuild search index
|
|
312
|
-
akm index --full # Full reindex
|
|
310
|
+
akm index # Rebuild search index
|
|
311
|
+
akm index --full # Full reindex
|
|
313
312
|
akm bundle list # List all sources
|
|
314
313
|
akm lint # Structural lint over the bundle; exits 0 regardless of findings
|
|
315
314
|
akm lint --fix # Auto-fix Tier 1 issues
|
|
@@ -399,7 +398,7 @@ akm agent --model sonnet --prompt "..." # Model override (aliases or exa
|
|
|
399
398
|
```sh
|
|
400
399
|
akm info # Capabilities, bundle dir, index stats, semantic-search status
|
|
401
400
|
akm health # Runtime diagnostics; exit 0 ok / 4 warn / 1 fail
|
|
402
|
-
akm health --report # Adds accept-rate
|
|
401
|
+
akm health --report # Adds accept-rate metrics
|
|
403
402
|
akm log # Append-only event stream (mutations, feedback, indexing)
|
|
404
403
|
akm log --ref <ref> # One asset's event trail
|
|
405
404
|
akm log --since @offset:<id> # Durable row-id cursor — poll this to follow the stream
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
"distill": { "enabled": false },
|
|
6
6
|
"consolidate": { "enabled": true, "allowedTypes": ["memory"], "maxChunkSize": 25 },
|
|
7
7
|
"memoryInference": { "enabled": false },
|
|
8
|
-
"graphExtraction": { "enabled": false },
|
|
9
8
|
"extract": { "enabled": false },
|
|
10
9
|
"triage": { "enabled": false },
|
|
11
10
|
"validation": { "enabled": false },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Standard improve pass — reflect, distill,
|
|
2
|
+
"description": "Standard improve pass — reflect, distill, consolidation (promotion plus the reviewed pair-pass retire/supersede proposals), and validation. Memory inference is listed below but only runs when experimental.improveAutonomy is set; improve-stage extract and proactive maintenance off.",
|
|
3
3
|
"processes": {
|
|
4
4
|
"reflect": {
|
|
5
5
|
"enabled": true,
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"distill": { "enabled": true, "allowedTypes": ["memory"], "requirePlannedRefs": true },
|
|
10
10
|
"consolidate": { "enabled": true, "allowedTypes": ["memory"] },
|
|
11
11
|
"memoryInference": { "enabled": true },
|
|
12
|
-
"graphExtraction": { "enabled": true },
|
|
13
12
|
"extract": { "enabled": false, "triage": { "enabled": true, "minScore": 2 } },
|
|
14
13
|
"validation": { "enabled": true },
|
|
15
14
|
"proactiveMaintenance": { "enabled": false, "dueDays": 30, "maxPerRun": 15 },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Opt-in proactive-maintenance pass — reflect, distill, proposal triage (promote, high budget), and the proactive-maintenance lane (maxPerRun 100); consolidate/memoryInference/
|
|
2
|
+
"description": "Opt-in proactive-maintenance pass — reflect, distill, proposal triage (promote, high budget), and the proactive-maintenance lane (maxPerRun 100); consolidate/memoryInference/extract off. Sync disabled: an interrupted run would otherwise leave an uncommitted backlog.",
|
|
3
3
|
"processes": {
|
|
4
4
|
"reflect": {
|
|
5
5
|
"enabled": true,
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"distill": { "enabled": true, "allowedTypes": ["memory"] },
|
|
9
9
|
"consolidate": { "enabled": false },
|
|
10
10
|
"memoryInference": { "enabled": false },
|
|
11
|
-
"graphExtraction": { "enabled": false },
|
|
12
11
|
"extract": { "enabled": false },
|
|
13
12
|
"validation": { "enabled": false },
|
|
14
13
|
"triage": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Reflect-only pass — no extract, distill, consolidate,
|
|
2
|
+
"description": "Reflect-only pass — no extract, distill, consolidate, or memoryInference.",
|
|
3
3
|
"processes": {
|
|
4
4
|
"reflect": {
|
|
5
5
|
"enabled": true,
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"distill": { "enabled": false },
|
|
10
10
|
"consolidate": { "enabled": false },
|
|
11
11
|
"memoryInference": { "enabled": false },
|
|
12
|
-
"graphExtraction": { "enabled": false },
|
|
13
12
|
"triage": { "enabled": false },
|
|
14
13
|
"validation": { "enabled": false },
|
|
15
14
|
"proactiveMaintenance": { "enabled": false }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Reflect + distill pass — reflect, distill, memoryInference, and proposal triage (promote); proactiveMaintenance/consolidate/
|
|
2
|
+
"description": "Reflect + distill pass — reflect, distill, memoryInference, and proposal triage (promote); proactiveMaintenance/consolidate/extract off. Sync disabled: an interrupted run would otherwise leave an uncommitted backlog.",
|
|
3
3
|
"processes": {
|
|
4
4
|
"reflect": {
|
|
5
5
|
"enabled": true,
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"distill": { "enabled": true, "allowedTypes": ["memory"], "requirePlannedRefs": false },
|
|
9
9
|
"consolidate": { "enabled": false },
|
|
10
10
|
"memoryInference": { "enabled": true },
|
|
11
|
-
"graphExtraction": { "enabled": false },
|
|
12
11
|
"extract": {
|
|
13
12
|
"enabled": false,
|
|
14
13
|
"timeoutMs": 300000,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
You compare two assets from one person's agent-memory library (memories and knowledge notes an AI coding agent reads). Asset A is the OLDER one and asset B the NEWER one, by the dates shown.
|
|
2
|
+
|
|
3
|
+
Classify the relation between them as exactly one of:
|
|
4
|
+
|
|
5
|
+
- "duplicate": they state the same durable facts. Wording, title or formatting may differ, but neither adds a claim a reader would need that the other lacks.
|
|
6
|
+
- "subsumed": one of them contains every durable claim of the other, plus more. The smaller one is redundant.
|
|
7
|
+
- "supersedes": B updates, corrects, reverses or replaces a claim in A (a newer version, a changed decision, a fixed bug, a new value). A is now stale or wrong on that point.
|
|
8
|
+
- "contradicts": they make logically exclusive claims about the same thing and nothing shows which one is current.
|
|
9
|
+
- "overlap": same subject, but each has durable claims the other lacks. Keeping both loses nothing; merging them would keep both sets of claims.
|
|
10
|
+
- "unrelated": different subjects or different facts that happen to share words.
|
|
11
|
+
|
|
12
|
+
Rules:
|
|
13
|
+
- A durable claim is a fact, decision, value, command, path, number or rule someone would act on. Ignore dates, headings, tags and phrasing.
|
|
14
|
+
- Prefer "overlap" over "duplicate" when either asset has a specific detail (a number, command, file, version or condition) that the other lacks.
|
|
15
|
+
- "supersedes" needs a specific claim in A that B changes. Being newer or longer is not enough.
|
|
16
|
+
- "contradicts" needs two claims that cannot both be true. Different scope, project or time is not a contradiction.
|
|
17
|
+
|
|
18
|
+
Set "redundant" to "A" or "B" when that asset could be removed with no loss (only for "duplicate" or "subsumed"; for "duplicate" name the less complete or older one), else null. Set "stale" to "A" when the relation is "supersedes", else null.
|
|
19
|
+
|
|
20
|
+
Answer ONLY with JSON: {"relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words>"}
|
|
@@ -8,11 +8,11 @@ updated: 2026-09-28
|
|
|
8
8
|
|
|
9
9
|
<!--
|
|
10
10
|
SOFT guidance only — advice, not a contract. Back-linking here is a RETRIEVAL
|
|
11
|
-
mechanism, not decoration: `xrefs:` frontmatter folds into the search index
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
mechanism, not decoration: `xrefs:` frontmatter folds into the search index
|
|
12
|
+
and is stored as a declared link `akm show` lists on both assets.
|
|
13
|
+
Over-linking degrades ranking, so these rules are deliberately conservative.
|
|
14
|
+
(LLM-wiki bundles carry their own xref system in their pages' frontmatter —
|
|
15
|
+
this convention is for in-stash assets.)
|
|
16
16
|
-->
|
|
17
17
|
|
|
18
18
|
# Back-linking conventions
|
|
@@ -21,9 +21,8 @@ Cross-references are how knowledge compounds instead of being re-derived every
|
|
|
21
21
|
session. In AKM they are also **indexed**: the strings in an asset's `xrefs:`
|
|
22
22
|
frontmatter fold into its search-hint text and are stored as links that
|
|
23
23
|
`akm show` lists on both assets (with `supersededBy:`, `contradictedBy:` and a
|
|
24
|
-
`.derived` memory's parent)
|
|
25
|
-
|
|
26
|
-
lever — which means both too few and too many hurt.
|
|
24
|
+
`.derived` memory's parent). So links are a retrieval lever — which means both
|
|
25
|
+
too few and too many hurt.
|
|
27
26
|
|
|
28
27
|
```yaml
|
|
29
28
|
---
|
|
@@ -48,10 +47,8 @@ xrefs:
|
|
|
48
47
|
- **Associative xrefs are discretionary — real relationships only.** Add one when
|
|
49
48
|
you already know a genuine load-bearing connection. Do **not** hit a link
|
|
50
49
|
quota by pointing at the topically-nearest sibling — a plausible-but-wrong
|
|
51
|
-
xref makes this asset a false search match for the other topic
|
|
52
|
-
|
|
53
|
-
want the graph to learn must be named in the body (e.g. open with "Corrects
|
|
54
|
-
knowledge/auth/oauth-refresh-races").
|
|
50
|
+
xref makes this asset a false search match for the other topic and a false
|
|
51
|
+
declared link on both assets' `akm show` output.
|
|
55
52
|
- **Cap total xrefs at ~5 (a heuristic, not a measured threshold).** Each xref
|
|
56
53
|
folds its ref tokens into THIS asset's search hints — past a handful, the
|
|
57
54
|
asset matches queries about several other topics and its own ranking signal
|
|
@@ -80,19 +77,20 @@ prose is indexed in the lowest-weight `content` field, so
|
|
|
80
77
|
`description:`/`when_to_use:` remain the primary orientation channel. Then
|
|
81
78
|
open the body with a plain title plus a one-line
|
|
82
79
|
orientation naming what it is, its scope/domain, and its key entities in
|
|
83
|
-
canonical spelling (`Postgres`, `OAuth`, `TLS`, `Acme`) —
|
|
84
|
-
|
|
85
|
-
Keep the canonical-spelling list in `facts/conventions/domains`
|
|
86
|
-
fragment `postgres` / `postgresql` / `pg`.
|
|
80
|
+
canonical spelling (`Postgres`, `OAuth`, `TLS`, `Acme`) — consistent spelling
|
|
81
|
+
keeps search matches from fragmenting across variants, and readers land here
|
|
82
|
+
from `akm show`. Keep the canonical-spelling list in `facts/conventions/domains`
|
|
83
|
+
so agents don't fragment `postgres` / `postgresql` / `pg`.
|
|
87
84
|
|
|
88
85
|
## Hubs are optional, not per-namespace obligations
|
|
89
86
|
|
|
90
87
|
A hub (a `knowledge/` overview page that xrefs the key assets in a domain) is
|
|
91
88
|
worth authoring for a **few genuinely high-traffic domains**. Do **not**
|
|
92
89
|
mandate a hub per namespace and do not edit a hub on every write: that is O(n)
|
|
93
|
-
maintenance, a concurrent-write contention point, and it flattens
|
|
94
|
-
|
|
95
|
-
effort on per-asset self-situating headers
|
|
90
|
+
maintenance, a concurrent-write contention point, and it flattens every linked
|
|
91
|
+
asset's declared links into a namespace-wide star centered on the hub. Let the
|
|
92
|
+
FTS index be the catalog; spend the effort on per-asset self-situating headers
|
|
93
|
+
instead.
|
|
96
94
|
|
|
97
95
|
## Keep assets atomic
|
|
98
96
|
|
|
@@ -48,8 +48,8 @@ volume justifies it.
|
|
|
48
48
|
## Canonical entity spellings
|
|
49
49
|
|
|
50
50
|
Pick ONE name per entity and use it everywhere in asset **bodies** — retrieval
|
|
51
|
-
is case-insensitive but treats aliases as different
|
|
52
|
-
fragment
|
|
51
|
+
is case-insensitive but treats aliases as different terms, so alias variants
|
|
52
|
+
fragment search matches. Extend as your stash grows.
|
|
53
53
|
|
|
54
54
|
- Postgres (not postgresql / pg)
|
|
55
55
|
- Kubernetes (not k8s)
|
|
@@ -325,7 +325,7 @@
|
|
|
325
325
|
|
|
326
326
|
<div class="chart-panel full-width">
|
|
327
327
|
<h3>Per-Phase Wall Time Decomposition (stacked, min)</h3>
|
|
328
|
-
<div id="chartPhases" class="ec tall" role="img" aria-label="Stacked area chart decomposing wall time per run into consolidation, memory inference,
|
|
328
|
+
<div id="chartPhases" class="ec tall" role="img" aria-label="Stacked area chart decomposing wall time per run into consolidation, memory inference, and unattributed phases"></div>
|
|
329
329
|
<div class="empty-overlay" id="emptyPhases">No runs in the selected slice.</div>
|
|
330
330
|
</div>
|
|
331
331
|
|
|
@@ -390,7 +390,7 @@
|
|
|
390
390
|
<div class="table-wrap">
|
|
391
391
|
<table>
|
|
392
392
|
<thead>
|
|
393
|
-
<tr><th>Started</th><th>Task</th><th>Strategy</th><th>Wall</th><th>Promoted</th><th>Merged</th><th>Contradicted</th><th>MI Written</th><th>
|
|
393
|
+
<tr><th>Started</th><th>Task</th><th>Strategy</th><th>Wall</th><th>Promoted</th><th>Merged</th><th>Contradicted</th><th>MI Written</th><th>Lint Fixed</th><th>Status</th></tr>
|
|
394
394
|
</thead>
|
|
395
395
|
<tbody id="lastRunsTable"></tbody>
|
|
396
396
|
</table>
|
|
@@ -539,7 +539,7 @@ mkChart('chartWallTime', {
|
|
|
539
539
|
// ── 2. Per-phase wall time decomposition (stacked area) ──────────────────────
|
|
540
540
|
mkChart('chartPhases', {
|
|
541
541
|
tooltip: baseTooltip,
|
|
542
|
-
legend: { ...baseLegend, data: ['Consolidation', 'Memory inference', '
|
|
542
|
+
legend: { ...baseLegend, data: ['Consolidation', 'Memory inference', 'Unattributed'] },
|
|
543
543
|
grid: denseGrid,
|
|
544
544
|
dataZoom: denseZoom,
|
|
545
545
|
xAxis: { type: 'category', data: xLabels, ...baseAxis },
|
|
@@ -547,7 +547,6 @@ mkChart('chartPhases', {
|
|
|
547
547
|
series: [
|
|
548
548
|
{ name: 'Consolidation', type: 'line', stack: 'p', areaStyle: { color: 'rgba(88,166,255,0.55)' }, lineStyle: { width: 0 }, showSymbol: false, data: rs.map(r => toMin(r.consDurationMs)) },
|
|
549
549
|
{ name: 'Memory inference', type: 'line', stack: 'p', areaStyle: { color: 'rgba(63,185,80,0.55)' }, lineStyle: { width: 0 }, showSymbol: false, data: rs.map(r => toMin(r.miDurationMs)) },
|
|
550
|
-
{ name: 'Graph extraction', type: 'line', stack: 'p', areaStyle: { color: 'rgba(188,140,255,0.55)' }, lineStyle: { width: 0 }, showSymbol: false, data: rs.map(r => toMin(r.geDurationMs)) },
|
|
551
550
|
{ name: 'Unattributed', type: 'line', stack: 'p', areaStyle: { color: 'rgba(210,153,34,0.45)' }, lineStyle: { width: 0 }, showSymbol: false, data: rs.map(r => toMin(r.otherMs)) },
|
|
552
551
|
],
|
|
553
552
|
});
|
|
@@ -695,7 +694,6 @@ tbody.innerHTML = '';
|
|
|
695
694
|
<td>${r.merged || 0}</td>
|
|
696
695
|
<td style="color:${(r.contradicted||0)>5?'var(--yellow)':'var(--text)'};">${r.contradicted || 0}</td>
|
|
697
696
|
<td style="color:var(--green);">${r.miWritten || 0}</td>
|
|
698
|
-
<td>${r.geEntities || 0}</td>
|
|
699
697
|
<td>${r.lintFixed || 0}</td>
|
|
700
698
|
<td>${badge}</td>
|
|
701
699
|
`;
|
|
@@ -38,7 +38,7 @@ const RETIRED_COMMAND_HINTS = {
|
|
|
38
38
|
events: "`akm events` moved in 0.9 — use `akm log`.",
|
|
39
39
|
// Removed observability surfaces.
|
|
40
40
|
history: "`akm history` was removed in 0.9 — use `akm log --ref <ref>` for an asset's event trail.",
|
|
41
|
-
graph: "`akm graph` was removed in 0.9
|
|
41
|
+
graph: "`akm graph` was removed in 0.9. The LLM entity graph it inspected was itself retired in 0.9.17-alpha.9 — use `akm show <ref>` for an asset's declared links.",
|
|
42
42
|
lessons: "`akm lessons` was removed in 0.9 — lesson strength is indexed; use `akm search --type lesson`.",
|
|
43
43
|
lesson: "`akm lesson` was removed in 0.9 — lesson strength is indexed; use `akm search --type lesson`.",
|
|
44
44
|
// Relocated guidance / removed asset verbs.
|
|
@@ -26,8 +26,17 @@
|
|
|
26
26
|
* validation) — conservative by design.
|
|
27
27
|
*/
|
|
28
28
|
import { UsageError } from "../core/errors.js";
|
|
29
|
+
import { warn } from "../core/warn.js";
|
|
29
30
|
import { cittyComparableName, findCittyTopLevelCommandIndex, toAliasArray, } from "./invocation.js";
|
|
30
31
|
import { retiredFlagHint } from "./retired-commands.js";
|
|
32
|
+
/**
|
|
33
|
+
* Commands that must never refuse on an unrecognized flag — `akm info` warns
|
|
34
|
+
* and continues instead of exiting 2, the same tolerance a bare `akm help
|
|
35
|
+
* --whatever` already gets for free from its group-command fallback (no
|
|
36
|
+
* resolved subcommand, so this gate stands down entirely). `info` is a leaf
|
|
37
|
+
* command, so it needs an explicit opt-in here instead.
|
|
38
|
+
*/
|
|
39
|
+
const UNKNOWN_FLAG_TOLERANT_COMMANDS = new Set(["info"]);
|
|
31
40
|
/** Flags citty implements itself, which no command declares. */
|
|
32
41
|
const IMPLICIT_FLAGS = ["help", "h", "version", "v"];
|
|
33
42
|
/**
|
|
@@ -172,6 +181,10 @@ function throwUnknownFlag(shown, attempted, known) {
|
|
|
172
181
|
? `Did you mean \`${suggestion}\`? Run the command with \`--help\` to see its accepted flags.`
|
|
173
182
|
: undefined);
|
|
174
183
|
}
|
|
184
|
+
/** The {@link UNKNOWN_FLAG_TOLERANT_COMMANDS} counterpart to {@link throwUnknownFlag}: report, don't refuse. */
|
|
185
|
+
function warnUnknownFlag(shown, known) {
|
|
186
|
+
warn(`[akm ${known.path.join(" ")}] ignoring unknown flag "${shown}" — run with --help to see its accepted flags.`);
|
|
187
|
+
}
|
|
175
188
|
/**
|
|
176
189
|
* Throw a {@link UsageError} naming the first flag the resolved command does
|
|
177
190
|
* not declare. Returns silently when every flag is known.
|
|
@@ -195,6 +208,7 @@ export function assertKnownFlags(root, rawArgs) {
|
|
|
195
208
|
const dynamicNamedFlagCommands = new Set(["workflow run", "task run", "task explain"]);
|
|
196
209
|
const dynamicWorkflowParams = dynamicNamedFlagCommands.has(known.path.join(" "));
|
|
197
210
|
const selfDiagnosed = SELF_DIAGNOSED_FLAGS.get(known.path.join(" "));
|
|
211
|
+
const tolerant = UNKNOWN_FLAG_TOLERANT_COMMANDS.has(known.path.join(" "));
|
|
198
212
|
for (let i = 0; i < ownArgs.length; i += 1) {
|
|
199
213
|
const token = ownArgs[i];
|
|
200
214
|
// Not a flag: positional, a bare `-` (stdin), or a negative number.
|
|
@@ -209,8 +223,13 @@ export function assertKnownFlags(root, rawArgs) {
|
|
|
209
223
|
for (let offset = 0; offset < shortFlags.length; offset += 1) {
|
|
210
224
|
const rawName = shortFlags[offset];
|
|
211
225
|
const candidate = cittyComparableName(rawName);
|
|
212
|
-
if (!known.names.has(candidate))
|
|
226
|
+
if (!known.names.has(candidate)) {
|
|
227
|
+
if (tolerant) {
|
|
228
|
+
warnUnknownFlag(token, known);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
213
231
|
throwUnknownFlag(token, `-${rawName}`, known);
|
|
232
|
+
}
|
|
214
233
|
if (known.valueFlags.has(candidate)) {
|
|
215
234
|
if (offset === shortFlags.length - 1)
|
|
216
235
|
i += 1;
|
|
@@ -239,6 +258,10 @@ export function assertKnownFlags(root, rawArgs) {
|
|
|
239
258
|
// frozen plan before a run is inserted. Short flags remain strict.
|
|
240
259
|
if (dynamicWorkflowParams)
|
|
241
260
|
continue;
|
|
261
|
+
if (tolerant) {
|
|
262
|
+
warnUnknownFlag(token.split("=")[0], known);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
242
265
|
throwUnknownFlag(token.split("=")[0], `--${rawName}`, known);
|
|
243
266
|
}
|
|
244
267
|
// Skip a declared value flag's value so `--reason "--x"` is not scanned.
|
package/dist/cli.js
CHANGED
|
@@ -645,6 +645,24 @@ export function shouldBypassConfigStartup(argv) {
|
|
|
645
645
|
const subcommand = args.slice(configIndex + 1).find((arg) => !arg.startsWith("-"));
|
|
646
646
|
return subcommand === "path";
|
|
647
647
|
}
|
|
648
|
+
/**
|
|
649
|
+
* Whether `argv` resolves to the top-level `info` command — used by
|
|
650
|
+
* `runCli` (and mirrored in `tests/_helpers/cli.ts`) to scope the startup
|
|
651
|
+
* config read's best-effort fallback to `info` alone. `info` is NOT on
|
|
652
|
+
* {@link shouldBypassConfigStartup}'s allowlist: unlike a bare bypass, it
|
|
653
|
+
* still reads a valid config's `output.format`/`output.detail` like any
|
|
654
|
+
* other command, it just must not be aborted by one it cannot read (see
|
|
655
|
+
* `assembleInfo`'s doc comment, src/commands/sources/info.ts, for why).
|
|
656
|
+
* Every other command reads config exactly as before — a broken config
|
|
657
|
+
* throws here and the command never runs.
|
|
658
|
+
*/
|
|
659
|
+
export function isInfoCommand(argv) {
|
|
660
|
+
const userArgs = argv.slice(2);
|
|
661
|
+
const separator = userArgs.indexOf("--");
|
|
662
|
+
const args = separator === -1 ? userArgs : userArgs.slice(0, separator);
|
|
663
|
+
const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
|
|
664
|
+
return (commandIndex >= 0 ? args[commandIndex] : undefined) === "info";
|
|
665
|
+
}
|
|
648
666
|
// ── Exit codes ──────────────────────────────────────────────────────────────
|
|
649
667
|
// Canonical table lives in `src/cli/shared.ts` (EXIT_CODES). These aliases keep
|
|
650
668
|
// the local call sites terse. EXIT_HEALTH_WARN (4) is the `akm health` "warn"
|
|
@@ -968,7 +986,34 @@ async function runCli() {
|
|
|
968
986
|
try {
|
|
969
987
|
applyEarlyStderrFlags(process.argv);
|
|
970
988
|
const bypassConfig = shouldBypassConfigStartup(process.argv);
|
|
971
|
-
|
|
989
|
+
// Off the bypass allowlist, every command reads config here exactly as
|
|
990
|
+
// it always has: an invalid config.json throws, `emitJsonError` reports
|
|
991
|
+
// it, and the command never runs — no side effect of its own body ever
|
|
992
|
+
// happens (a lock taken, a network call made, a database opened
|
|
993
|
+
// read-write). `akm info` is the ONE exception (see `assembleInfo`'s
|
|
994
|
+
// doc comment, src/commands/sources/info.ts): only ITS read is
|
|
995
|
+
// best-effort, falling back to `DEFAULT_CONFIG.output` instead of
|
|
996
|
+
// throwing. Scoped narrowly on purpose — an earlier version of this fix
|
|
997
|
+
// made the read best-effort for every command, which silently changed
|
|
998
|
+
// outcomes across the CLI (some commands that should refuse at exit 78
|
|
999
|
+
// ran anyway; `health`/`index`/`config set`/`feedback` still failed,
|
|
1000
|
+
// but only after already taking a lock, opening a database read-write,
|
|
1001
|
+
// or making a network call).
|
|
1002
|
+
let outputDefaults = DEFAULT_CONFIG.output ?? {};
|
|
1003
|
+
if (!bypassConfig) {
|
|
1004
|
+
if (isInfoCommand(process.argv)) {
|
|
1005
|
+
try {
|
|
1006
|
+
outputDefaults = loadConfig().output ?? {};
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
outputDefaults = DEFAULT_CONFIG.output ?? {};
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
outputDefaults = loadConfig().output ?? {};
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
initOutputMode(process.argv, outputDefaults);
|
|
972
1017
|
}
|
|
973
1018
|
catch (error) {
|
|
974
1019
|
emitJsonError(error);
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
* `memory-cleanup-archive` advisory for `akm health` (item 4, 0.9.17-alpha.9
|
|
6
|
+
* plan §5.4/§8 step 8).
|
|
7
|
+
*
|
|
8
|
+
* Reports the archive's size and file count for every bundle, git-backed or
|
|
9
|
+
* not. A bundle with no `.git` of its own keeps every retirement's archived
|
|
10
|
+
* bytes forever (the purge sweep never runs there at all), so that alone is
|
|
11
|
+
* reported. A git-backed bundle can ALSO have bytes the purge sweep will
|
|
12
|
+
* never remove: `.git` presence alone does not prove a retirement was ever
|
|
13
|
+
* committed (`proposal accept` only commits for a `kind: "git"` write
|
|
14
|
+
* target, and a `kind: "filesystem"` bundle that merely happens to have a
|
|
15
|
+
* `.git` directory never gets one) — those bytes sit there indefinitely,
|
|
16
|
+
* however old they get, with nothing else to say so. This checks the SAME
|
|
17
|
+
* git state `purgeGracedArchive` checks (tracked, clean, verifiable) and
|
|
18
|
+
* reports how much of the archive fails it.
|
|
19
|
+
*
|
|
20
|
+
* Silent whenever there is nothing to say: the archive does not exist or is
|
|
21
|
+
* empty, or (for a git-backed bundle) every byte in it is purgeable once it
|
|
22
|
+
* ages out.
|
|
23
|
+
*/
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { MEMORY_ARCHIVE_REL } from "../../core/asset/memory-archive.js";
|
|
27
|
+
import { toPosix } from "../../core/common.js";
|
|
28
|
+
import { checkGitPathSafety, isGitBackedStash } from "../../sources/providers/git-stash.js";
|
|
29
|
+
import { MAX_WALK_ENTRIES, sizeOfPath } from "./data-dir-usage.js";
|
|
30
|
+
/**
|
|
31
|
+
* Build the `memory-cleanup-archive` advisory, or `undefined` when there is
|
|
32
|
+
* nothing to report.
|
|
33
|
+
*/
|
|
34
|
+
export function collectArchiveUsageAdvisory(stashDir) {
|
|
35
|
+
const archiveRoot = path.join(stashDir, MEMORY_ARCHIVE_REL);
|
|
36
|
+
if (!fs.existsSync(archiveRoot))
|
|
37
|
+
return undefined;
|
|
38
|
+
if (!isGitBackedStash(stashDir)) {
|
|
39
|
+
const usage = sizeOfPath(archiveRoot, { remaining: MAX_WALK_ENTRIES });
|
|
40
|
+
if (usage.files === 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
const lowerBound = usage.truncated ? ` (a lower bound — the walk stopped after ${MAX_WALK_ENTRIES} entries)` : "";
|
|
43
|
+
return {
|
|
44
|
+
name: "memory-cleanup-archive",
|
|
45
|
+
kind: "deterministic",
|
|
46
|
+
status: "pass",
|
|
47
|
+
confidence: "high",
|
|
48
|
+
message: `${usage.files} archived file(s), ${usage.bytes} byte(s)${lowerBound} in .akm/memory-cleanup/archive — ` +
|
|
49
|
+
"this bundle has no git history, so the purge sweep leaves it untouched.",
|
|
50
|
+
evidence: { files: usage.files, bytes: usage.bytes, truncated: usage.truncated },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// Git-backed: the SAME three checks purgeGracedArchive runs (B1, G10) —
|
|
54
|
+
// computed once here, not per file, and reused via `onFile` below instead
|
|
55
|
+
// of a second walk of the same tree. A failed git check here fails the
|
|
56
|
+
// same way purgeGracedArchive's own sweep would: nothing in the archive
|
|
57
|
+
// can be proven purgeable, so every byte counts as unpurgeable rather
|
|
58
|
+
// than guessing (`checkGitPathSafety`'s `isSafe` is always `false` when
|
|
59
|
+
// `ok` is `false`).
|
|
60
|
+
const gitSafety = checkGitPathSafety(stashDir, MEMORY_ARCHIVE_REL);
|
|
61
|
+
let unpurgeableFiles = 0;
|
|
62
|
+
let unpurgeableBytes = 0;
|
|
63
|
+
const usage = sizeOfPath(archiveRoot, { remaining: MAX_WALK_ENTRIES }, (filePath, bytes) => {
|
|
64
|
+
const key = toPosix(path.relative(stashDir, filePath));
|
|
65
|
+
if (!gitSafety.isSafe(key)) {
|
|
66
|
+
unpurgeableFiles++;
|
|
67
|
+
unpurgeableBytes += bytes;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
if (usage.files === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (unpurgeableFiles === 0)
|
|
73
|
+
return undefined; // everything here is purgeable once it ages out — nothing to say
|
|
74
|
+
const lowerBound = usage.truncated ? ` (a lower bound — the walk stopped after ${MAX_WALK_ENTRIES} entries)` : "";
|
|
75
|
+
return {
|
|
76
|
+
name: "memory-cleanup-archive",
|
|
77
|
+
kind: "deterministic",
|
|
78
|
+
status: "warn",
|
|
79
|
+
confidence: "high",
|
|
80
|
+
message: `${usage.files} archived file(s), ${usage.bytes} byte(s)${lowerBound} in .akm/memory-cleanup/archive; ` +
|
|
81
|
+
`${unpurgeableFiles} file(s), ${unpurgeableBytes} byte(s) of that cannot be purged (untracked, modified, ` +
|
|
82
|
+
"or unverifiable in git) — commit them so the purge sweep can remove them once they age out.",
|
|
83
|
+
evidence: {
|
|
84
|
+
files: usage.files,
|
|
85
|
+
bytes: usage.bytes,
|
|
86
|
+
truncated: usage.truncated,
|
|
87
|
+
unpurgeableFiles,
|
|
88
|
+
unpurgeableBytes,
|
|
89
|
+
gitStateKnown: gitSafety.ok,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -41,7 +41,7 @@ const DOMINANT_SUBDIR_PERCENT_THRESHOLD = 50;
|
|
|
41
41
|
* cap the walk stops descending further and the advisory says its size
|
|
42
42
|
* figures are a lower bound.
|
|
43
43
|
*/
|
|
44
|
-
const MAX_WALK_ENTRIES = 100_000;
|
|
44
|
+
export const MAX_WALK_ENTRIES = 100_000;
|
|
45
45
|
/**
|
|
46
46
|
* Below this the data dir is not worth an opinion. The advisory exists for
|
|
47
47
|
* disk blowups (74 GB in the incident); on a small directory a ratio is
|
|
@@ -59,31 +59,42 @@ function liveDbBytesFor(name, sizes) {
|
|
|
59
59
|
return ((sizes.get(name)?.bytes ?? 0) + (sizes.get(`${name}-wal`)?.bytes ?? 0) + (sizes.get(`${name}-shm`)?.bytes ?? 0));
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
|
-
* Recursively sum file sizes under `root` (stat-only,
|
|
63
|
-
* followed so a cyclic or huge-target symlink can't blow up the
|
|
64
|
-
* `budget` is a shared mutable counter across the whole tree so the
|
|
65
|
-
*
|
|
62
|
+
* Recursively sum file sizes (and count files) under `root` (stat-only,
|
|
63
|
+
* symlinks not followed so a cyclic or huge-target symlink can't blow up the
|
|
64
|
+
* walk). `budget` is a shared mutable counter across the whole tree so the
|
|
65
|
+
* entry cap applies to the walk as a whole, not per-branch — callers
|
|
66
|
+
* typically pass {@link MAX_WALK_ENTRIES}, sized for this module's own data
|
|
67
|
+
* dir walk, but a smaller/larger budget is fine for a different tree.
|
|
68
|
+
* Shared with the `archive-usage` advisory (N2) — the same "don't let a
|
|
69
|
+
* pathological tree hang a health check" concern applies to both.
|
|
70
|
+
*
|
|
71
|
+
* `onFile`, when given, is called once per leaf file (path, bytes) as the
|
|
72
|
+
* walk visits it — `archive-usage` uses this to classify each file's git
|
|
73
|
+
* state without a second, separate walk of the same tree.
|
|
66
74
|
*/
|
|
67
|
-
function sizeOfPath(root, budget) {
|
|
75
|
+
export function sizeOfPath(root, budget, onFile) {
|
|
68
76
|
let stat;
|
|
69
77
|
try {
|
|
70
78
|
stat = fs.lstatSync(root);
|
|
71
79
|
}
|
|
72
80
|
catch {
|
|
73
|
-
return { bytes: 0, truncated: false };
|
|
81
|
+
return { bytes: 0, files: 0, truncated: false };
|
|
74
82
|
}
|
|
75
83
|
if (stat.isSymbolicLink())
|
|
76
|
-
return { bytes: 0, truncated: false };
|
|
77
|
-
if (!stat.isDirectory())
|
|
78
|
-
|
|
84
|
+
return { bytes: 0, files: 0, truncated: false };
|
|
85
|
+
if (!stat.isDirectory()) {
|
|
86
|
+
onFile?.(root, stat.size);
|
|
87
|
+
return { bytes: stat.size, files: 1, truncated: false };
|
|
88
|
+
}
|
|
79
89
|
let entries;
|
|
80
90
|
try {
|
|
81
91
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
82
92
|
}
|
|
83
93
|
catch {
|
|
84
|
-
return { bytes: 0, truncated: false };
|
|
94
|
+
return { bytes: 0, files: 0, truncated: false };
|
|
85
95
|
}
|
|
86
96
|
let bytes = 0;
|
|
97
|
+
let files = 0;
|
|
87
98
|
let truncated = false;
|
|
88
99
|
for (const entry of entries) {
|
|
89
100
|
if (budget.remaining <= 0) {
|
|
@@ -91,12 +102,13 @@ function sizeOfPath(root, budget) {
|
|
|
91
102
|
break;
|
|
92
103
|
}
|
|
93
104
|
budget.remaining--;
|
|
94
|
-
const sub = sizeOfPath(path.join(root, entry.name), budget);
|
|
105
|
+
const sub = sizeOfPath(path.join(root, entry.name), budget, onFile);
|
|
95
106
|
bytes += sub.bytes;
|
|
107
|
+
files += sub.files;
|
|
96
108
|
if (sub.truncated)
|
|
97
109
|
truncated = true;
|
|
98
110
|
}
|
|
99
|
-
return { bytes, truncated };
|
|
111
|
+
return { bytes, files, truncated };
|
|
100
112
|
}
|
|
101
113
|
/** `1610612736` -> `"1.5G"`. Values under 10 in a unit keep one decimal; 10+ round to an integer. */
|
|
102
114
|
function formatBytes(bytes) {
|