akm-cli 0.7.5 → 0.8.0-rc.6

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.
Files changed (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
@@ -1,30 +1,19 @@
1
- /**
2
- * Typed error classes for structured exit code classification.
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 init` to create the default stash, or set stashDir in your config.",
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":"..."}\'` to configure the LLM.',
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 = {
@@ -92,3 +81,35 @@ export class NotFoundError extends Error {
92
81
  return this._hint ?? NOT_FOUND_HINTS[this.code];
93
82
  }
94
83
  }
84
+ /**
85
+ * Test-isolation guard helper.
86
+ *
87
+ * `src/core/paths.ts` throws `ConfigError("TEST_ISOLATION_MISSING")` under
88
+ * `bun test` when `AKM_STASH_DIR` is set without a paired data-dir or
89
+ * state-dir override. That throw must never be swallowed by best-effort
90
+ * catches around DB/data-dir operations — otherwise the guard's loud failure
91
+ * silently degrades into a "no result" outcome (cold cache, missing snapshot,
92
+ * etc.) and the underlying test leak goes undetected.
93
+ *
94
+ * Call `rethrowIfTestIsolationError(err)` from any catch block that returns
95
+ * a fallback value (null, [], empty result) after touching DB or data-dir
96
+ * paths. It re-throws when the caught error is the guard violation, otherwise
97
+ * does nothing so the existing benign-fallback path can proceed unchanged.
98
+ *
99
+ * Usage:
100
+ * try {
101
+ * const db = openDatabase();
102
+ * // ...
103
+ * } catch (err) {
104
+ * rethrowIfTestIsolationError(err);
105
+ * // existing benign-fallback handling
106
+ * }
107
+ */
108
+ export function isTestIsolationError(err) {
109
+ return err instanceof ConfigError && err.code === "TEST_ISOLATION_MISSING";
110
+ }
111
+ export function rethrowIfTestIsolationError(err) {
112
+ if (isTestIsolationError(err)) {
113
+ throw err;
114
+ }
115
+ }
@@ -1,47 +1,28 @@
1
- /**
2
- * Append-only events stream `events.jsonl` (#204).
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 { getCacheDir } from "./paths";
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
- * Default events.jsonl location: `<cacheDir>/events.jsonl`.
32
- *
33
- * Env-isolation caveat: `getCacheDir()` reads `XDG_CACHE_HOME` at the time of
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(getCacheDir(), "events.jsonl");
15
+ return path.join(getDataDir(), "events.jsonl");
42
16
  }
43
- function resolvePath(ctx) {
44
- return ctx?.filePath ?? getEventsPath();
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,129 +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
- * The id field is intentionally omitted on write (the line index is the
54
- * id; the reader assigns it). Keeping it off the wire avoids a coordination
55
- * step between concurrent appenders.
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
- const envelope = {
62
- schemaVersion: 1,
63
- ts,
64
- eventType: input.eventType,
65
- ...(input.ref !== undefined ? { ref: input.ref } : {}),
66
- ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
67
- };
68
- const line = `${JSON.stringify(envelope)}\n`;
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
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
- // O_APPEND guarantees atomic appends ≤ PIPE_BUF (4 KiB on Linux); our
72
- // events are well under that ceiling, so concurrent processes can write
73
- // safely without locking. `appendFileSync` opens with `'a'` which sets
74
- // O_APPEND.
75
- fs.appendFileSync(filePath, line, { encoding: "utf8" });
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
- const message = err instanceof Error ? err.message : String(err);
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 — `sinceOffset`
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 filePath = resolvePath(ctx);
91
- if (!fs.existsSync(filePath)) {
92
- return { events: [], nextOffset: 0 };
88
+ const dbPath = resolveDbPath(ctx);
89
+ let db;
90
+ try {
91
+ db = openStateDatabase(dbPath);
93
92
  }
94
- const stat = fs.statSync(filePath);
95
- const startOffset = options.sinceOffset && options.sinceOffset > 0 ? options.sinceOffset : 0;
96
- if (startOffset >= stat.size) {
97
- return { events: [], nextOffset: stat.size };
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 length = stat.size - startOffset;
102
- const buf = Buffer.alloc(length);
103
- fs.readSync(fd, buf, 0, length, startOffset);
104
- const text = buf.toString("utf8");
105
- const events = parseEventLines(text, options, startOffset);
106
- return { events, nextOffset: stat.size };
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
- fs.closeSync(fd);
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
- const tags = envelope.metadata?.tags ?? [];
162
- if (options.excludeTags?.some((t) => tags.includes(t)))
163
- return false;
164
- if (options.includeTags && !options.includeTags.every((t) => tags.includes(t)))
165
- return false;
166
- return true;
167
- }
168
121
  /**
169
- * Follow events.jsonl. Polls at `intervalMs` (default 75ms) and emits
170
- * 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
171
124
  * `maxEvents` events have been observed, or when `maxDurationMs` elapses.
172
125
  *
173
- * The polling cursor is byte-offset based, so concurrent writers cannot
174
- * cause skips: between two reads we always pick up everything appended
175
- * since the last `nextOffset`.
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`.
176
129
  */
177
130
  export async function tailEvents(options = {}, ctx) {
178
131
  const intervalMs = options.intervalMs ?? 75;
@@ -223,7 +176,7 @@ export async function tailEvents(options = {}, ctx) {
223
176
  cursor = result.nextOffset;
224
177
  for (const event of result.events) {
225
178
  // Apply --since filter inside the polling loop too — the cursor is
226
- // byte-offset so it can hand us events the user filtered out.
179
+ // rowid-based so it can hand us events the user filtered out.
227
180
  if (options.since && event.ts && event.ts < options.since)
228
181
  continue;
229
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
+ }
@@ -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
  *
@@ -150,9 +153,3 @@ export function parseYamlScalar(value) {
150
153
  }
151
154
  return value;
152
155
  }
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
- }
@@ -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
  *
@@ -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
+ }
@@ -0,0 +1,62 @@
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
+ * Shared memory belief-state machinery (C-3 / #382).
6
+ *
7
+ * Extracted from `memory-improve.ts` so both `akmConsolidate` and
8
+ * `analyzeMemoryCleanup` can emit `MemoryBeliefTransitionLogRecord` entries
9
+ * through a unified state-transition model.
10
+ *
11
+ * # Design
12
+ *
13
+ * The 4-state belief lifecycle (active → superseded | contradicted → archived)
14
+ * was previously encoded only in `memory-improve.ts`. `akmConsolidate` used a
15
+ * flat merge/delete/promote model with no belief states, causing the two engines
16
+ * to diverge. This module:
17
+ *
18
+ * 1. Re-exports the belief-state types from `memory-improve.ts` so callers
19
+ * can import from one canonical location.
20
+ * 2. Provides `writeContradictEdge` — a shared primitive that both engines
21
+ * use when an LLM (or heuristic) identifies a contradiction between two
22
+ * memories. This is the bridge between `akmConsolidate`'s LLM-detected
23
+ * contradictions and `resolveFamilyContradictions`' SCC resolver.
24
+ *
25
+ * # References
26
+ *
27
+ * - Zep / Graphiti (arXiv:2501.13956 §3) — unified belief-revision pipeline
28
+ * - MemOS (arXiv:2507.03724) — formal archive/merge/transition with shared state model
29
+ */
30
+ import fs from "node:fs";
31
+ import { assembleAsset } from "./asset-serialize";
32
+ import { parseFrontmatter } from "./frontmatter";
33
+ // ── Contradiction edge writer ─────────────────────────────────────────────────
34
+ /**
35
+ * Write `contradictedBy` and `beliefState: contradicted` edges to a memory
36
+ * file's frontmatter (C-3 / #382).
37
+ *
38
+ * This is the shared primitive used by:
39
+ * - `akmConsolidate` when its LLM plan includes a `contradict` op
40
+ * - `memory-contradiction-detect.ts` for the M-1 automated contradiction pass
41
+ * - `resolveFamilyContradictions` in `memory-improve.ts` for SCC resolution
42
+ *
43
+ * Idempotent: if the `contradictedByRef` is already in `contradictedBy`,
44
+ * the file is not rewritten.
45
+ *
46
+ * @param filePath - Absolute path to the memory markdown file.
47
+ * @param contradictedByRef - The ref that contradicts this memory.
48
+ */
49
+ export function writeContradictEdge(filePath, contradictedByRef) {
50
+ const raw = fs.readFileSync(filePath, "utf8");
51
+ const parsed = parseFrontmatter(raw);
52
+ const existing = Array.isArray(parsed.data.contradictedBy) ? parsed.data.contradictedBy : [];
53
+ if (existing.includes(contradictedByRef))
54
+ return; // Already written — idempotent.
55
+ const nextContradictedBy = [...new Set([...existing, contradictedByRef])].sort();
56
+ const nextFrontmatter = {
57
+ ...parsed.data,
58
+ contradictedBy: nextContradictedBy,
59
+ beliefState: "contradicted",
60
+ };
61
+ fs.writeFileSync(filePath, assembleAsset(nextFrontmatter, parsed.content), "utf8");
62
+ }