akm-cli 0.7.4 → 0.8.0-rc.10
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 +224 -1
- package/README.md +22 -6
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2631 -1440
- package/dist/commands/add-cli.js +279 -0
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +2122 -0
- package/dist/commands/curate.js +45 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +1081 -73
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +15 -24
- package/dist/commands/extract-cli.js +127 -0
- package/dist/commands/extract-prompt.js +204 -0
- package/dist/commands/extract.js +477 -0
- package/dist/commands/feedback-cli.js +331 -0
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +1302 -0
- package/dist/commands/help/help-accept.md +12 -0
- package/dist/commands/help/help-improve.md +69 -0
- package/dist/commands/help/help-proposals.md +18 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +11 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +217 -0
- package/dist/commands/improve-profiles.js +166 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +2373 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +196 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +3 -0
- package/dist/commands/proposal.js +67 -12
- package/dist/commands/propose.js +120 -45
- package/dist/commands/reflect.js +1104 -60
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +70 -7
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +158 -60
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +437 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +17 -2
- package/dist/core/asset-registry.js +12 -17
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +67 -1
- package/dist/core/common.js +182 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +534 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +364 -968
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +105 -135
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -8
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +280 -14
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +512 -42
- package/dist/core/state-db.js +1068 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +64 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +198 -489
- package/dist/indexer/db.js +990 -108
- package/dist/indexer/ensure-index.js +136 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -114
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +547 -309
- package/dist/indexer/llm-cache.js +52 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +167 -160
- package/dist/indexer/memory-inference.js +152 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +275 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +6 -17
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +250 -36
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +183 -35
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +69 -0
- package/dist/integrations/session-logs/inline-refs.js +35 -0
- package/dist/integrations/session-logs/pre-filter.js +152 -0
- package/dist/integrations/session-logs/providers/claude-code.js +282 -0
- package/dist/integrations/session-logs/providers/opencode.js +258 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +79 -88
- package/dist/llm/embedder.js +20 -29
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +42 -1
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +95 -48
- package/dist/llm/graph-extract.js +676 -72
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +80 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +292 -0
- package/dist/output/cli-hints-short.md +66 -0
- package/dist/output/cli-hints.js +7 -311
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +306 -258
- package/dist/output/shapes/curate.js +56 -0
- package/dist/output/shapes/distill.js +10 -0
- package/dist/output/shapes/env-list.js +19 -0
- package/dist/output/shapes/events.js +11 -0
- package/dist/output/shapes/helpers.js +424 -0
- package/dist/output/shapes/history.js +7 -0
- package/dist/output/shapes/passthrough.js +102 -0
- package/dist/output/shapes/proposal-accept.js +7 -0
- package/dist/output/shapes/proposal-diff.js +7 -0
- package/dist/output/shapes/proposal-list.js +7 -0
- package/dist/output/shapes/proposal-producer.js +11 -0
- package/dist/output/shapes/proposal-reject.js +7 -0
- package/dist/output/shapes/proposal-show.js +7 -0
- package/dist/output/shapes/registry-search.js +6 -0
- package/dist/output/shapes/registry.js +30 -0
- package/dist/output/shapes/search.js +6 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes/show.js +6 -0
- package/dist/output/shapes/vault-list.js +19 -0
- package/dist/output/shapes.js +51 -511
- package/dist/output/text/add.js +6 -0
- package/dist/output/text/clone.js +6 -0
- package/dist/output/text/config.js +6 -0
- package/dist/output/text/curate.js +6 -0
- package/dist/output/text/distill.js +7 -0
- package/dist/output/text/enable-disable.js +7 -0
- package/dist/output/text/events.js +10 -0
- package/dist/output/text/feedback.js +6 -0
- package/dist/output/text/helpers.js +1039 -0
- package/dist/output/text/history.js +7 -0
- package/dist/output/text/import.js +6 -0
- package/dist/output/text/index.js +6 -0
- package/dist/output/text/info.js +6 -0
- package/dist/output/text/init.js +6 -0
- package/dist/output/text/list.js +6 -0
- package/dist/output/text/proposal-producer.js +8 -0
- package/dist/output/text/proposal.js +11 -0
- package/dist/output/text/registry-commands.js +11 -0
- package/dist/output/text/registry.js +30 -0
- package/dist/output/text/remember.js +6 -0
- package/dist/output/text/remove.js +6 -0
- package/dist/output/text/save.js +6 -0
- package/dist/output/text/search.js +6 -0
- package/dist/output/text/show.js +6 -0
- package/dist/output/text/update.js +6 -0
- package/dist/output/text/upgrade.js +6 -0
- package/dist/output/text/vault.js +16 -0
- package/dist/output/text/wiki.js +15 -0
- package/dist/output/text/workflow.js +14 -0
- package/dist/output/text.js +44 -1093
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +4 -1
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +71 -50
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17750 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +179 -20
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +227 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +141 -2
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +91 -89
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +79 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +10 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.4.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +20 -0
- package/docs/migration/release-notes/0.8.0.md +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +29 -11
- package/dist/commands/install-audit.js +0 -381
- package/dist/commands/vault.js +0 -333
- package/dist/templates/wiki-templates.js +0 -100
package/dist/core/errors.js
CHANGED
|
@@ -1,37 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* - ConfigError -> exit 78 (configuration / environment problems)
|
|
5
|
-
* - UsageError -> exit 2 (bad CLI arguments or invalid input)
|
|
6
|
-
* - NotFoundError -> exit 1 (requested resource missing)
|
|
7
|
-
*
|
|
8
|
-
* Each error carries a machine-readable `code` field. Codes are stable
|
|
9
|
-
* identifiers safe to consume from scripts and JSON output. Existing throw
|
|
10
|
-
* sites without an explicit code receive a default code per error class so
|
|
11
|
-
* older call sites continue to compile and behave unchanged.
|
|
12
|
-
*
|
|
13
|
-
* Each error also exposes a `hint()` method returning an actionable hint
|
|
14
|
-
* string (or `undefined`). Hints can be supplied at construction time or
|
|
15
|
-
* derived from the error `code` via the per-class default mapping below.
|
|
16
|
-
* The CLI surfaces this via `error.hint()` rather than message-regex parsing.
|
|
17
|
-
*/
|
|
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/.
|
|
18
4
|
/**
|
|
19
5
|
* Default hint for each ConfigError code. Keep these short, actionable, and
|
|
20
6
|
* imperative. Returning undefined means "no canned hint".
|
|
21
7
|
*/
|
|
22
8
|
const CONFIG_HINTS = {
|
|
23
|
-
STASH_DIR_NOT_FOUND: "Run `akm
|
|
9
|
+
STASH_DIR_NOT_FOUND: "Run `akm setup` to create and configure your stash, or set stashDir in your config.",
|
|
24
10
|
STASH_DIR_NOT_A_DIRECTORY: "The configured stashDir exists but isn't a directory. Update stashDir to point at a folder.",
|
|
25
11
|
STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update stashDir.",
|
|
26
12
|
EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
|
|
27
|
-
LLM_NOT_CONFIGURED: 'Run `akm config set llm \'{"endpoint":"...","model":"..."}\'
|
|
13
|
+
LLM_NOT_CONFIGURED: 'Run `akm setup` or `akm config set profiles.llm.default \'{"endpoint":"...","model":"..."}\' to configure an LLM profile.',
|
|
14
|
+
TEST_ISOLATION_MISSING: "Under bun test, when AKM_STASH_DIR is set you MUST also set XDG_DATA_HOME (or AKM_DATA_DIR) and XDG_STATE_HOME (or AKM_STATE_DIR) to temp directories so the test does not touch the developer's real ~/.local/share/akm or ~/.local/state/akm.",
|
|
15
|
+
SETUP_TMP_STASH_REFUSED: "Use a persistent directory, or set AKM_FORCE_SETUP_TMP_STASH=1 to opt in to a sandboxed setup (setup also pre-sets AKM_STASH_DIR so config and cache writes auto-isolate into $stashDir/.akm/ — host config is preserved).",
|
|
16
|
+
UNSAFE_STASH_DIR: "Choose a path inside your home directory (e.g. ~/akm) or another empty workspace. The stash directory cannot be the filesystem root, your home directory itself, or a sensitive system path like /etc, /var, ~/.config, or ~/.ssh.",
|
|
28
17
|
};
|
|
29
18
|
/** Default hint for each UsageError code. */
|
|
30
19
|
const USAGE_HINTS = {
|
|
31
20
|
INVALID_FLAG_VALUE: "Run `akm <command> --help` to see accepted values.",
|
|
32
21
|
INVALID_SOURCE_VALUE: "Pick one of: stash, registry, both.",
|
|
33
22
|
INVALID_FORMAT_VALUE: "Pick one of: json, jsonl, text, yaml.",
|
|
34
|
-
INVALID_DETAIL_VALUE: "Pick one of: brief, normal, full
|
|
23
|
+
INVALID_DETAIL_VALUE: "Pick one of: brief, normal, full. For agent/summary projections use --shape.",
|
|
24
|
+
INVALID_SHAPE_VALUE: "Pick one of: human, agent, summary (summary is only valid on `akm show`).",
|
|
35
25
|
INVALID_JSON_CONFIG_VALUE: 'Quote JSON values in your shell, for example: akm config set embedding \'{"endpoint":"http://localhost:11434/v1/embeddings","model":"nomic-embed-text"}\'.',
|
|
36
26
|
MISSING_OR_AMBIGUOUS_TARGET: "Use `akm update --all` or pass a target like `akm update npm:@scope/pkg` (not both).",
|
|
37
27
|
TARGET_NOT_UPDATABLE: "Run `akm list` to view your sources, then retry with one of those values.",
|
|
@@ -92,3 +82,35 @@ export class NotFoundError extends Error {
|
|
|
92
82
|
return this._hint ?? NOT_FOUND_HINTS[this.code];
|
|
93
83
|
}
|
|
94
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Test-isolation guard helper.
|
|
87
|
+
*
|
|
88
|
+
* `src/core/paths.ts` throws `ConfigError("TEST_ISOLATION_MISSING")` under
|
|
89
|
+
* `bun test` when `AKM_STASH_DIR` is set without a paired data-dir or
|
|
90
|
+
* state-dir override. That throw must never be swallowed by best-effort
|
|
91
|
+
* catches around DB/data-dir operations — otherwise the guard's loud failure
|
|
92
|
+
* silently degrades into a "no result" outcome (cold cache, missing snapshot,
|
|
93
|
+
* etc.) and the underlying test leak goes undetected.
|
|
94
|
+
*
|
|
95
|
+
* Call `rethrowIfTestIsolationError(err)` from any catch block that returns
|
|
96
|
+
* a fallback value (null, [], empty result) after touching DB or data-dir
|
|
97
|
+
* paths. It re-throws when the caught error is the guard violation, otherwise
|
|
98
|
+
* does nothing so the existing benign-fallback path can proceed unchanged.
|
|
99
|
+
*
|
|
100
|
+
* Usage:
|
|
101
|
+
* try {
|
|
102
|
+
* const db = openDatabase();
|
|
103
|
+
* // ...
|
|
104
|
+
* } catch (err) {
|
|
105
|
+
* rethrowIfTestIsolationError(err);
|
|
106
|
+
* // existing benign-fallback handling
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
export function isTestIsolationError(err) {
|
|
110
|
+
return err instanceof ConfigError && err.code === "TEST_ISOLATION_MISSING";
|
|
111
|
+
}
|
|
112
|
+
export function rethrowIfTestIsolationError(err) {
|
|
113
|
+
if (isTestIsolationError(err)) {
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/core/events.js
CHANGED
|
@@ -1,47 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Every mutating CLI verb funnels through `appendEvent` so external
|
|
5
|
-
* observers (sync, replication, audit, dashboards) can react to stash
|
|
6
|
-
* changes by tailing a single file. The file is plain newline-delimited
|
|
7
|
-
* JSON; each line is a self-contained event envelope.
|
|
8
|
-
*
|
|
9
|
-
* The helper is the only thing in akm that writes to events.jsonl. It
|
|
10
|
-
* accepts injectable `now()` and `path` so tests can pin time and use a
|
|
11
|
-
* tmpdir without any global mutation.
|
|
12
|
-
*
|
|
13
|
-
* Format (each line):
|
|
14
|
-
* { "schemaVersion": 1, "id": <number>, "ts": "<ISO>",
|
|
15
|
-
* "eventType": "<verb>", "ref"?: "<asset-ref>", ... }
|
|
16
|
-
*
|
|
17
|
-
* - `id` is a monotonic integer per file. We use the file's pre-write
|
|
18
|
-
* byte length as a durable cursor for `--since` (stable across processes
|
|
19
|
-
* because every appender holds an O_APPEND write). Callers can also pass
|
|
20
|
-
* a string ISO timestamp to `--since` and we filter by `ts >= since`.
|
|
21
|
-
* - `ts` is ISO-8601 (UTC, millisecond precision).
|
|
22
|
-
*
|
|
23
|
-
* The event `id` is derived at read time (line index) — the file itself
|
|
24
|
-
* is the source of truth, so the writer never has to coordinate with a
|
|
25
|
-
* counter. Tail consumers can persist a byte offset (durable cursor).
|
|
26
|
-
*/
|
|
27
|
-
import fs from "node:fs";
|
|
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/.
|
|
28
4
|
import path from "node:path";
|
|
29
|
-
import {
|
|
5
|
+
import { rethrowIfTestIsolationError } from "./errors";
|
|
6
|
+
import { getDataDir } from "./paths";
|
|
7
|
+
import { insertEvent, openStateDatabase, readStateEvents } from "./state-db";
|
|
8
|
+
import { error } from "./warn";
|
|
30
9
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* each call. Two cooperating processes (e.g. one writing events, one tailing)
|
|
35
|
-
* MUST inherit the same `XDG_CACHE_HOME` or they will read/write different
|
|
36
|
-
* `events.jsonl` files. This is the same env-isolation behaviour as the rest
|
|
37
|
-
* of akm — config, indexes, and caches all key off XDG paths — so set
|
|
38
|
-
* `XDG_CACHE_HOME` consistently across processes that share the events bus.
|
|
10
|
+
* Legacy events.jsonl path — used only by the migration script
|
|
11
|
+
* (`scripts/migrate-storage.ts`) to import existing event history into
|
|
12
|
+
* state.db. No events are written here by akm v0.9+.
|
|
39
13
|
*/
|
|
40
14
|
export function getEventsPath() {
|
|
41
|
-
return path.join(
|
|
15
|
+
return path.join(getDataDir(), "events.jsonl");
|
|
42
16
|
}
|
|
43
|
-
|
|
44
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the state.db path from context:
|
|
19
|
+
* 1. `ctx.dbPath` — explicit override (test seam)
|
|
20
|
+
* 2. default — `<dataDir>/state.db`
|
|
21
|
+
*/
|
|
22
|
+
function resolveDbPath(ctx) {
|
|
23
|
+
if (ctx?.dbPath)
|
|
24
|
+
return ctx.dbPath;
|
|
25
|
+
return path.join(getDataDir(), "state.db");
|
|
45
26
|
}
|
|
46
27
|
function resolveNow(ctx) {
|
|
47
28
|
return ctx?.now ?? Date.now;
|
|
@@ -50,124 +31,101 @@ function resolveNow(ctx) {
|
|
|
50
31
|
* Append a single event. Best-effort: a write failure is logged once to
|
|
51
32
|
* stderr but never propagates — observability must not break mutation.
|
|
52
33
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
34
|
+
* Events are written exclusively to the `events` table in `state.db`.
|
|
35
|
+
*
|
|
36
|
+
* I1: when `ctx.db` is provided (a pre-opened long-lived connection), the
|
|
37
|
+
* function writes directly to that handle without opening or closing the DB.
|
|
38
|
+
* This eliminates per-event open/migrate/close overhead for high-frequency
|
|
39
|
+
* callers such as `akmImprove`.
|
|
56
40
|
*/
|
|
57
41
|
export function appendEvent(input, ctx) {
|
|
58
|
-
const filePath = resolvePath(ctx);
|
|
59
42
|
const now = resolveNow(ctx);
|
|
60
43
|
const ts = new Date(now()).toISOString();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
44
|
+
// Fast path: caller provided a long-lived connection — use it directly.
|
|
45
|
+
if (ctx?.db) {
|
|
46
|
+
try {
|
|
47
|
+
insertEvent(ctx.db, {
|
|
48
|
+
eventType: input.eventType,
|
|
49
|
+
ts,
|
|
50
|
+
ref: input.ref,
|
|
51
|
+
metadata: input.metadata,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
error(`akm: appendEvent failed: ${String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Default path: open, insert, close.
|
|
60
|
+
const dbPath = resolveDbPath(ctx);
|
|
69
61
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
62
|
+
const db = openStateDatabase(dbPath);
|
|
63
|
+
try {
|
|
64
|
+
insertEvent(db, {
|
|
65
|
+
eventType: input.eventType,
|
|
66
|
+
ts,
|
|
67
|
+
ref: input.ref,
|
|
68
|
+
metadata: input.metadata,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
db.close();
|
|
73
|
+
}
|
|
76
74
|
}
|
|
77
75
|
catch (err) {
|
|
76
|
+
// Never mask the bun-test isolation guard as a silent "events failed".
|
|
77
|
+
rethrowIfTestIsolationError(err);
|
|
78
78
|
// Best-effort: events stream failures must not break the mutating verb.
|
|
79
79
|
// Surface once to stderr so operators can diagnose.
|
|
80
|
-
|
|
81
|
-
process.stderr.write(`akm: events.jsonl append failed (${message})\n`);
|
|
80
|
+
error(`akm: appendEvent failed: ${String(err)}`);
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
83
|
/**
|
|
85
84
|
* Read all events matching the filter. Returns a `nextOffset` that callers
|
|
86
|
-
* can persist between processes for monotonic resumption
|
|
87
|
-
* is the durable cursor referenced in the acceptance criteria.
|
|
85
|
+
* can persist between processes for monotonic resumption.
|
|
88
86
|
*/
|
|
89
87
|
export function readEvents(options = {}, ctx) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
const dbPath = resolveDbPath(ctx);
|
|
89
|
+
let db;
|
|
90
|
+
try {
|
|
91
|
+
db = openStateDatabase(dbPath);
|
|
93
92
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
catch (err) {
|
|
94
|
+
// Never mask the bun-test isolation guard as "no events".
|
|
95
|
+
rethrowIfTestIsolationError(err);
|
|
96
|
+
// DB does not exist yet or cannot be opened — return empty result.
|
|
97
|
+
return { events: [], nextOffset: 0 };
|
|
98
98
|
}
|
|
99
|
-
const fd = fs.openSync(filePath, "r");
|
|
100
99
|
try {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
const { events: rawEvents, nextId } = readStateEvents(db, {
|
|
101
|
+
sinceId: options.sinceOffset,
|
|
102
|
+
since: options.since,
|
|
103
|
+
type: options.type,
|
|
104
|
+
ref: options.ref,
|
|
105
|
+
});
|
|
106
|
+
// Apply tag filters in application code (same as the old JSONL implementation).
|
|
107
|
+
const events = rawEvents.filter((envelope) => {
|
|
108
|
+
const tags = envelope.metadata?.tags ?? [];
|
|
109
|
+
if (options.excludeTags?.some((t) => tags.includes(t)))
|
|
110
|
+
return false;
|
|
111
|
+
if (options.includeTags && !options.includeTags.every((t) => tags.includes(t)))
|
|
112
|
+
return false;
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
return { events, nextOffset: nextId };
|
|
107
116
|
}
|
|
108
117
|
finally {
|
|
109
|
-
|
|
118
|
+
db.close();
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
|
-
function parseEventLines(text, options, startOffset) {
|
|
113
|
-
// Each line that ends with \n is a complete event. A trailing partial
|
|
114
|
-
// line (no terminating \n) is ignored — the next read will pick it up
|
|
115
|
-
// once it is fully written.
|
|
116
|
-
const out = [];
|
|
117
|
-
let lineStart = 0;
|
|
118
|
-
// The envelope id is the 1-based line index across the whole file. We
|
|
119
|
-
// approximate that here as the line index from the start of the read
|
|
120
|
-
// window plus a synthetic offset — for callers using `--since`, the
|
|
121
|
-
// absolute id is less useful than the byte cursor anyway. To keep ids
|
|
122
|
-
// monotonic across reads we use absolute byte position as a stable
|
|
123
|
-
// surrogate identifier.
|
|
124
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
125
|
-
if (text.charCodeAt(i) !== 10 /* \n */)
|
|
126
|
-
continue;
|
|
127
|
-
const line = text.slice(lineStart, i);
|
|
128
|
-
const absStart = startOffset + lineStart;
|
|
129
|
-
lineStart = i + 1;
|
|
130
|
-
if (!line.trim())
|
|
131
|
-
continue;
|
|
132
|
-
let parsed;
|
|
133
|
-
try {
|
|
134
|
-
parsed = JSON.parse(line);
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
// Skip malformed lines — better than crashing the read pipeline.
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
const envelope = {
|
|
141
|
-
schemaVersion: 1,
|
|
142
|
-
id: absStart,
|
|
143
|
-
ts: typeof parsed.ts === "string" ? parsed.ts : "",
|
|
144
|
-
eventType: typeof parsed.eventType === "string" ? parsed.eventType : "unknown",
|
|
145
|
-
...(typeof parsed.ref === "string" ? { ref: parsed.ref } : {}),
|
|
146
|
-
...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}),
|
|
147
|
-
};
|
|
148
|
-
if (!matchesFilter(envelope, options))
|
|
149
|
-
continue;
|
|
150
|
-
out.push(envelope);
|
|
151
|
-
}
|
|
152
|
-
return out;
|
|
153
|
-
}
|
|
154
|
-
function matchesFilter(envelope, options) {
|
|
155
|
-
if (options.type && envelope.eventType !== options.type)
|
|
156
|
-
return false;
|
|
157
|
-
if (options.ref && envelope.ref !== options.ref)
|
|
158
|
-
return false;
|
|
159
|
-
if (options.since && envelope.ts && envelope.ts < options.since)
|
|
160
|
-
return false;
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
121
|
/**
|
|
164
|
-
* Follow events.
|
|
165
|
-
* every new event to `onEvent`. Resolves when `signal` aborts, when
|
|
122
|
+
* Follow the events table in state.db. Polls at `intervalMs` (default 75ms)
|
|
123
|
+
* and emits every new event to `onEvent`. Resolves when `signal` aborts, when
|
|
166
124
|
* `maxEvents` events have been observed, or when `maxDurationMs` elapses.
|
|
167
125
|
*
|
|
168
|
-
* The polling cursor is
|
|
169
|
-
* cause skips: between two reads we always pick up everything
|
|
170
|
-
*
|
|
126
|
+
* The polling cursor is a monotonic SQLite rowid so concurrent writers cannot
|
|
127
|
+
* cause skips: between two reads we always pick up everything inserted since
|
|
128
|
+
* the last `nextOffset`.
|
|
171
129
|
*/
|
|
172
130
|
export async function tailEvents(options = {}, ctx) {
|
|
173
131
|
const intervalMs = options.intervalMs ?? 75;
|
|
@@ -178,7 +136,13 @@ export async function tailEvents(options = {}, ctx) {
|
|
|
178
136
|
// we start polling. This matches the documented behaviour of `tail
|
|
179
137
|
// --since`: emit existing events that match, then follow.
|
|
180
138
|
if (options.sinceOffset === undefined) {
|
|
181
|
-
const initial = readEvents({
|
|
139
|
+
const initial = readEvents({
|
|
140
|
+
since: options.since,
|
|
141
|
+
type: options.type,
|
|
142
|
+
ref: options.ref,
|
|
143
|
+
excludeTags: options.excludeTags,
|
|
144
|
+
includeTags: options.includeTags,
|
|
145
|
+
}, ctx);
|
|
182
146
|
for (const event of initial.events) {
|
|
183
147
|
collected.push(event);
|
|
184
148
|
options.onEvent?.(event);
|
|
@@ -202,11 +166,17 @@ export async function tailEvents(options = {}, ctx) {
|
|
|
202
166
|
}
|
|
203
167
|
function tick() {
|
|
204
168
|
try {
|
|
205
|
-
const result = readEvents({
|
|
169
|
+
const result = readEvents({
|
|
170
|
+
sinceOffset: cursor,
|
|
171
|
+
type: options.type,
|
|
172
|
+
ref: options.ref,
|
|
173
|
+
excludeTags: options.excludeTags,
|
|
174
|
+
includeTags: options.includeTags,
|
|
175
|
+
}, ctx);
|
|
206
176
|
cursor = result.nextOffset;
|
|
207
177
|
for (const event of result.events) {
|
|
208
178
|
// Apply --since filter inside the polling loop too — the cursor is
|
|
209
|
-
//
|
|
179
|
+
// rowid-based so it can hand us events the user filtered out.
|
|
210
180
|
if (options.since && event.ts && event.ts < options.since)
|
|
211
181
|
continue;
|
|
212
182
|
collected.push(event);
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
5
|
+
import { isProcessAlive } from "./common";
|
|
6
|
+
/**
|
|
7
|
+
* Atomically create a sentinel at `lockPath` with `payload` as the body.
|
|
8
|
+
* Returns true if we now own the lock, false if a sentinel already
|
|
9
|
+
* exists (EEXIST). Throws any other error (permissions, missing parent
|
|
10
|
+
* dir, etc.) — callers must ensure the parent directory exists.
|
|
11
|
+
*
|
|
12
|
+
* `payload` is typically `String(process.pid)` for the simple cases or
|
|
13
|
+
* a small JSON envelope for callers that want richer metadata
|
|
14
|
+
* (improve.ts records pid + startedAt so audit can correlate runs).
|
|
15
|
+
*/
|
|
16
|
+
export function tryAcquireLockSync(lockPath, payload) {
|
|
17
|
+
try {
|
|
18
|
+
fs.writeFileSync(lockPath, payload, { flag: "wx" });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === "EEXIST")
|
|
23
|
+
return false;
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Inspect an existing sentinel at `lockPath` without modifying it.
|
|
29
|
+
* Returns:
|
|
30
|
+
* - `absent` if the file does not exist.
|
|
31
|
+
* - `stale` if the file is present but should be reclaimed (the holding
|
|
32
|
+
* PID is dead, the content is unparseable, or the lock has exceeded
|
|
33
|
+
* `staleAfterMs`). Includes the failure reason so callers can log it.
|
|
34
|
+
* - `held` if the lock has a live holder and is not yet age-expired.
|
|
35
|
+
*
|
|
36
|
+
* Does NOT remove the file. Callers decide recovery policy.
|
|
37
|
+
*/
|
|
38
|
+
export function probeLock(lockPath, opts) {
|
|
39
|
+
let rawContent;
|
|
40
|
+
let ageMs;
|
|
41
|
+
try {
|
|
42
|
+
rawContent = fs.readFileSync(lockPath, "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err.code === "ENOENT")
|
|
46
|
+
return { state: "absent" };
|
|
47
|
+
return { state: "stale", reason: "unreadable" };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const stat = fs.statSync(lockPath);
|
|
51
|
+
ageMs = Date.now() - stat.mtimeMs;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Stat failed even though read succeeded — race-y removal in flight.
|
|
55
|
+
return { state: "stale", reason: "unreadable", rawContent };
|
|
56
|
+
}
|
|
57
|
+
const holderPid = extractHolderPid(rawContent);
|
|
58
|
+
if (holderPid === undefined) {
|
|
59
|
+
return { state: "stale", reason: "invalid_pid", ageMs, rawContent };
|
|
60
|
+
}
|
|
61
|
+
if (!isProcessAlive(holderPid)) {
|
|
62
|
+
return { state: "stale", reason: "pid_dead", holderPid, ageMs, rawContent };
|
|
63
|
+
}
|
|
64
|
+
if (opts?.staleAfterMs !== undefined && ageMs > opts.staleAfterMs) {
|
|
65
|
+
return { state: "stale", reason: "age_exceeded", holderPid, ageMs, rawContent };
|
|
66
|
+
}
|
|
67
|
+
return { state: "held", holderPid, ageMs, rawContent };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Remove a lock file. Idempotent — silently ignores ENOENT. Used both to
|
|
71
|
+
* reclaim stale locks (after probeLock returns `state: "stale"`) and to
|
|
72
|
+
* release locks we own (after a successful tryAcquireLockSync).
|
|
73
|
+
*/
|
|
74
|
+
export function releaseLock(lockPath) {
|
|
75
|
+
try {
|
|
76
|
+
fs.unlinkSync(lockPath);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Sentinel already gone — fine.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Extract a PID from a sentinel body. Accepts the two shapes used across
|
|
84
|
+
* the codebase: a bare numeric string (config-io, vault, lockfile) and
|
|
85
|
+
* a JSON object with a `pid` field (improve). Returns undefined when the
|
|
86
|
+
* body is unparseable or yields a non-positive integer.
|
|
87
|
+
*/
|
|
88
|
+
function extractHolderPid(content) {
|
|
89
|
+
const trimmed = content.trim();
|
|
90
|
+
if (!trimmed)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (trimmed.startsWith("{")) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(trimmed);
|
|
95
|
+
const pid = typeof parsed.pid === "number" ? parsed.pid : Number.NaN;
|
|
96
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const pid = Number.parseInt(trimmed, 10);
|
|
103
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
104
|
+
}
|
package/dist/core/frontmatter.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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/.
|
|
1
4
|
/**
|
|
2
5
|
* Shared frontmatter parsing utilities.
|
|
3
6
|
*
|
|
@@ -18,6 +21,9 @@
|
|
|
18
21
|
* booleans, or numbers.
|
|
19
22
|
* - **No nested objects beyond one level**: Only a single level of indented
|
|
20
23
|
* key-value pairs is supported.
|
|
24
|
+
* - **Block scalars**: `|` (literal), `|-` (strip), and `|+` (keep) block
|
|
25
|
+
* scalars are supported for multi-line string values as emitted by the
|
|
26
|
+
* `yaml` library's `stringify`.
|
|
21
27
|
*/
|
|
22
28
|
export function parseFrontmatter(raw) {
|
|
23
29
|
const parsedBlock = parseFrontmatterBlock(raw);
|
|
@@ -26,10 +32,18 @@ export function parseFrontmatter(raw) {
|
|
|
26
32
|
}
|
|
27
33
|
const data = {};
|
|
28
34
|
let currentKey = null;
|
|
29
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* "scalar" | "list" | "object" | "pending" | "block" —
|
|
37
|
+
* "pending" means empty value, mode determined by next line.
|
|
38
|
+
* "block" means we are accumulating lines for a `|`-block scalar.
|
|
39
|
+
*/
|
|
30
40
|
let mode = "scalar";
|
|
31
41
|
let nested = null;
|
|
32
42
|
let currentList = null;
|
|
43
|
+
/** Lines collected while in "block" mode. */
|
|
44
|
+
let blockLines = null;
|
|
45
|
+
/** Block scalar chomping: "clip" (|), "strip" (|-), "keep" (|+). */
|
|
46
|
+
let blockChomping = "clip";
|
|
33
47
|
const flushPending = () => {
|
|
34
48
|
// Called when we start a new top-level key and the previous key was still "pending".
|
|
35
49
|
// An empty-value key followed by another top-level key means it was an empty scalar.
|
|
@@ -37,7 +51,41 @@ export function parseFrontmatter(raw) {
|
|
|
37
51
|
data[currentKey] = "";
|
|
38
52
|
}
|
|
39
53
|
};
|
|
54
|
+
const flushBlock = () => {
|
|
55
|
+
// Commit the accumulated block-scalar lines to `data[currentKey]`.
|
|
56
|
+
if (mode !== "block" || currentKey === null || blockLines === null)
|
|
57
|
+
return;
|
|
58
|
+
// De-indent: strip the common 2-space prefix `yaml.stringify` emits.
|
|
59
|
+
const deindented = blockLines.map((l) => (l.startsWith(" ") ? l.slice(2) : l));
|
|
60
|
+
// Chomping: apply trailing-newline policy.
|
|
61
|
+
// "clip" (|): single trailing newline.
|
|
62
|
+
// "strip" (|-): no trailing newline.
|
|
63
|
+
// "keep" (|+): keep all trailing newlines as-is.
|
|
64
|
+
if (blockChomping === "keep") {
|
|
65
|
+
data[currentKey] = deindented.join("\n");
|
|
66
|
+
}
|
|
67
|
+
else if (blockChomping === "strip") {
|
|
68
|
+
data[currentKey] = deindented.join("\n").replace(/\n+$/, "");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// "clip": exactly one trailing newline
|
|
72
|
+
data[currentKey] = `${deindented.join("\n").replace(/\n+$/, "")}\n`;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
40
75
|
for (const line of parsedBlock.frontmatter.split(/\r?\n/)) {
|
|
76
|
+
// If we are in block-scalar mode, collect indented lines or end the block.
|
|
77
|
+
if (mode === "block") {
|
|
78
|
+
if (line.startsWith(" ") || line === "") {
|
|
79
|
+
// Continuation of the block scalar (indented content or blank line).
|
|
80
|
+
blockLines.push(line);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Non-indented line ends the block scalar — flush and fall through to
|
|
84
|
+
// parse the new line as a top-level key.
|
|
85
|
+
flushBlock();
|
|
86
|
+
mode = "scalar";
|
|
87
|
+
blockLines = null;
|
|
88
|
+
}
|
|
41
89
|
// Block-sequence item: "- value" or " - value" (optional 2-space indent)
|
|
42
90
|
// Only match when the current key is in list or pending mode.
|
|
43
91
|
const seqItem = line.match(/^(?: {2})?- (.*)$/);
|
|
@@ -51,6 +99,18 @@ export function parseFrontmatter(raw) {
|
|
|
51
99
|
currentList.push(parseYamlScalar(seqItem[1].trim()));
|
|
52
100
|
continue;
|
|
53
101
|
}
|
|
102
|
+
// Plain-style multi-line scalar continuation: a 2-space-indented line that
|
|
103
|
+
// is not a sequence item or nested key. YAML plain scalars fold newlines
|
|
104
|
+
// into a single space, so we append with a space. This handles LLM-emitted
|
|
105
|
+
// descriptions like:
|
|
106
|
+
// description: Use 4-colon outer containers when mixing
|
|
107
|
+
// nesting depths in markdown-it-container plugins.
|
|
108
|
+
// Without this, only the first line is captured and the truncation
|
|
109
|
+
// heuristic wrongly flags it as cut off mid-sentence.
|
|
110
|
+
if (mode === "scalar" && currentKey !== null && /^ {2}\S/.test(line)) {
|
|
111
|
+
data[currentKey] = `${String(data[currentKey])} ${line.trim()}`;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
54
114
|
// Indented nested key-value (object under a key with empty value)
|
|
55
115
|
const indented = line.match(/^ {2}(\w[\w-]*):\s*(.+)$/);
|
|
56
116
|
if (indented && currentKey !== null && (mode === "object" || mode === "pending")) {
|
|
@@ -72,7 +132,15 @@ export function parseFrontmatter(raw) {
|
|
|
72
132
|
flushPending();
|
|
73
133
|
currentKey = top[1];
|
|
74
134
|
const value = top[2].trim();
|
|
75
|
-
if (value === "") {
|
|
135
|
+
if (value === "|" || value === "|-" || value === "|+") {
|
|
136
|
+
// Block scalar header — collect subsequent indented lines.
|
|
137
|
+
mode = "block";
|
|
138
|
+
blockLines = [];
|
|
139
|
+
blockChomping = value === "|-" ? "strip" : value === "|+" ? "keep" : "clip";
|
|
140
|
+
nested = null;
|
|
141
|
+
currentList = null;
|
|
142
|
+
}
|
|
143
|
+
else if (value === "") {
|
|
76
144
|
// Defer mode decision until we see the next line
|
|
77
145
|
mode = "pending";
|
|
78
146
|
nested = null;
|
|
@@ -83,6 +151,7 @@ export function parseFrontmatter(raw) {
|
|
|
83
151
|
// Inline flow array: tags: [ops, networking]
|
|
84
152
|
mode = "list";
|
|
85
153
|
nested = null;
|
|
154
|
+
currentList = null;
|
|
86
155
|
currentList = parseFlowArray(value);
|
|
87
156
|
data[currentKey] = currentList;
|
|
88
157
|
}
|
|
@@ -93,6 +162,10 @@ export function parseFrontmatter(raw) {
|
|
|
93
162
|
data[currentKey] = parseYamlScalar(value);
|
|
94
163
|
}
|
|
95
164
|
}
|
|
165
|
+
// Flush any in-progress block scalar at end of frontmatter.
|
|
166
|
+
if (mode === "block") {
|
|
167
|
+
flushBlock();
|
|
168
|
+
}
|
|
96
169
|
// Flush the last key if it was still pending (empty value, no continuation)
|
|
97
170
|
flushPending();
|
|
98
171
|
return {
|
|
@@ -150,9 +223,3 @@ export function parseYamlScalar(value) {
|
|
|
150
223
|
}
|
|
151
224
|
return value;
|
|
152
225
|
}
|
|
153
|
-
/**
|
|
154
|
-
* Coerce an unknown value to a trimmed string, or return undefined if empty/non-string.
|
|
155
|
-
*/
|
|
156
|
-
export function toStringOrUndefined(value) {
|
|
157
|
-
return typeof value === "string" && value.trim() ? value : undefined;
|
|
158
|
-
}
|
package/dist/core/lesson-lint.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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/.
|
|
1
4
|
/**
|
|
2
5
|
* Deterministic frontmatter lint for `lesson` assets (v1 spec §13).
|
|
3
6
|
*
|
package/dist/core/markdown.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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/.
|
|
1
4
|
import { parseFrontmatter } from "./frontmatter";
|
|
2
5
|
// ── Parsing ─────────────────────────────────────────────────────────────────
|
|
3
6
|
export function parseMarkdownToc(content) {
|
|
@@ -75,3 +78,20 @@ export function formatToc(toc) {
|
|
|
75
78
|
parts.push(`\n${toc.totalLines} lines total`);
|
|
76
79
|
return parts.join("\n");
|
|
77
80
|
}
|
|
81
|
+
// ── Fence stripping ──────────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Best-effort fence stripping. Strips `<think>` reasoning blocks emitted by
|
|
84
|
+
* local LLMs (e.g. Qwen3) before the content, which otherwise breaks YAML
|
|
85
|
+
* frontmatter detection. Only strips outer triple-fence pairs — leaves inner
|
|
86
|
+
* code blocks intact.
|
|
87
|
+
*/
|
|
88
|
+
export function stripMarkdownFences(raw) {
|
|
89
|
+
const stripped = raw
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
92
|
+
.trim();
|
|
93
|
+
const fence = stripped.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/i);
|
|
94
|
+
if (fence)
|
|
95
|
+
return fence[1].trim();
|
|
96
|
+
return stripped;
|
|
97
|
+
}
|