akm-cli 0.10.261008-beta → 0.10.261010-beta

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 (82) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/STABILITY.md +25 -8
  3. package/dist/api.d.ts +6 -0
  4. package/dist/api.js +12 -1
  5. package/dist/assets/prompts/consolidate-pair-check.md +2 -0
  6. package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
  7. package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
  8. package/dist/assets/prompts/consolidate-pair.md +4 -12
  9. package/dist/assets/prompts/memory-infer-system.md +1 -1
  10. package/dist/assets/prompts/memory-infer-user.md +3 -2
  11. package/dist/assets/ui/app.js +59 -2
  12. package/dist/assets/ui/asset.js +71 -6
  13. package/dist/assets/ui/bundles.js +80 -3
  14. package/dist/assets/ui/diff-view.js +1 -1
  15. package/dist/assets/ui/editor.js +103 -0
  16. package/dist/assets/ui/index.html +2 -0
  17. package/dist/assets/ui/lib.js +51 -1
  18. package/dist/assets/ui/markdown.js +86 -19
  19. package/dist/assets/ui/search.js +25 -5
  20. package/dist/assets/ui/styles.css +70 -0
  21. package/dist/commands/health/improve-metrics.js +11 -1
  22. package/dist/commands/health/md-report.js +3 -3
  23. package/dist/commands/health.js +1 -2
  24. package/dist/commands/improve/consolidate/pair-pass.js +59 -31
  25. package/dist/commands/improve/extract.js +18 -12
  26. package/dist/commands/improve/loop-stages.js +2 -2
  27. package/dist/commands/improve/memory/memory-belief.js +2 -2
  28. package/dist/commands/proposal/repository.js +44 -13
  29. package/dist/commands/read/curate.js +78 -19
  30. package/dist/commands/read/knowledge.js +109 -77
  31. package/dist/commands/read/search-cli.js +25 -11
  32. package/dist/commands/read/search.js +3 -3
  33. package/dist/commands/read/show.js +47 -1
  34. package/dist/commands/sources/bundle-cli.js +4 -6
  35. package/dist/commands/sources/installed-stashes.js +10 -19
  36. package/dist/commands/ui/api.js +95 -5
  37. package/dist/commands/ui/document-source.js +51 -0
  38. package/dist/commands/ui/git-read.js +18 -2
  39. package/dist/commands/ui/proposal-actions.js +5 -3
  40. package/dist/commands/ui/server.js +25 -13
  41. package/dist/commands/ui/ui-cli.js +3 -3
  42. package/dist/commands/ui/workspace-actions.js +264 -0
  43. package/dist/commands/workflow-cli.js +3 -1
  44. package/dist/core/adapter/adapters/akm-adapter.js +1 -0
  45. package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
  46. package/dist/core/adapter/adapters/okf-adapter.js +3 -16
  47. package/dist/core/asset/akm-markdown.js +19 -3
  48. package/dist/core/asset/markdown-link-targets.js +43 -0
  49. package/dist/core/asset/markdown.js +14 -6
  50. package/dist/core/config/config.js +6 -4
  51. package/dist/core/non-task-input.js +67 -3
  52. package/dist/core/write-source.js +2 -2
  53. package/dist/indexer/index-writer-lock.js +5 -1
  54. package/dist/indexer/passes/memory-inference.js +28 -6
  55. package/dist/indexer/passes/metadata.js +17 -0
  56. package/dist/indexer/scan/doc-to-entry.js +1 -0
  57. package/dist/indexer/search/db-search.js +16 -7
  58. package/dist/indexer/search/search-hit-enrichers.js +3 -3
  59. package/dist/llm/memory-infer.js +23 -14
  60. package/dist/output/shapes/curate.js +3 -1
  61. package/dist/output/shapes/helpers.js +6 -0
  62. package/dist/output/text/show-format.js +7 -0
  63. package/dist/scripts/akm-migrate-node.js +1820 -329
  64. package/dist/scripts/akm-migrate.js +1820 -329
  65. package/dist/storage/repositories/index-entries-repository.js +16 -0
  66. package/dist/storage/repositories/index-fts-repository.js +28 -6
  67. package/dist/storage/repositories/index-vec-repository.js +19 -3
  68. package/dist/workflows/exec/environment.js +4 -8
  69. package/dist/workflows/parser.js +13 -4
  70. package/docs/architecture/0.10-process-rules.md +9 -7
  71. package/docs/architecture/architecture.md +4 -0
  72. package/docs/architecture/internals/storage-locations.md +1 -1
  73. package/docs/architecture/persisted-data-compat.md +1 -1
  74. package/docs/guides/author-workflows.md +78 -28
  75. package/docs/guides/bundles.md +16 -2
  76. package/docs/reference/api.md +19 -1
  77. package/docs/reference/cli.md +90 -18
  78. package/docs/reference/env-and-secrets.md +11 -0
  79. package/docs/reference/memory.md +7 -6
  80. package/docs/reference/ui-api.md +84 -8
  81. package/docs/reference/workflow-schema.md +63 -32
  82. package/package.json +2 -1
@@ -0,0 +1,264 @@
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 { execFile } from "node:child_process";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { parseRefInput } from "../../core/asset/resolve-ref.js";
7
+ import { getConfigValueSource, loadConfig, mutateConfig, resetConfigCache, sanitizeConfigForWrite, } from "../../core/config/config.js";
8
+ import { AkmError, UsageError } from "../../core/errors.js";
9
+ import { appendEvent } from "../../core/events.js";
10
+ import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
11
+ import { setConfigValue, unsetConfigValue } from "../config-cli.js";
12
+ import { writeMarkdownAsset } from "../read/knowledge.js";
13
+ import { readDocumentSource } from "./document-source.js";
14
+ import { assertWriteLeaseFree, classifyProposalError } from "./proposal-actions.js";
15
+ import { redactForUi, redactUrlsInText } from "./redact.js";
16
+ const ROUTES = {
17
+ "/api/feedback": "feedback",
18
+ "/api/bundles/add": "bundle-add",
19
+ "/api/bundles/remove": "bundle-remove",
20
+ "/api/bundles/update": "bundle-update",
21
+ "/api/bundles/settings": "bundle-settings",
22
+ "/api/knowledge/create": "knowledge-create",
23
+ "/api/asset/save": "document-save",
24
+ };
25
+ export function matchWorkspaceAction(path) {
26
+ return ROUTES[path] ?? null;
27
+ }
28
+ function object(value) {
29
+ if (!value || typeof value !== "object" || Array.isArray(value))
30
+ throw new UsageError("A JSON object is required.");
31
+ return value;
32
+ }
33
+ function text(body, key) {
34
+ const value = body[key];
35
+ if (typeof value !== "string" || !value.trim())
36
+ throw new UsageError(`${key} is required.`);
37
+ return value.trim();
38
+ }
39
+ function positional(body, key) {
40
+ const value = text(body, key);
41
+ if (value.startsWith("-"))
42
+ throw new UsageError(`${key} must be a target, not a command option. Use ./ for a relative path beginning with a dash.`);
43
+ return value;
44
+ }
45
+ function preserveUnrelatedConfig(config = loadConfig()) {
46
+ if (!isDeepStrictEqual(sanitizeConfigForWrite(config, { quiet: true }), config)) {
47
+ throw new UsageError("AKM's config sanitizer would remove unrelated credentials. Move literal API keys to $VAR or secret:// references and remove registry URL credentials using the CLI before changing bundle configuration in the UI.");
48
+ }
49
+ }
50
+ /** The error kind behind each exit code of the CLI contract (`EXIT_CODES`, `src/cli/shared.ts`). */
51
+ const KIND_BY_EXIT = { 1: "not-found", 2: "usage", 75: "transient", 78: "config" };
52
+ /** Preserve the CLI's error identity while crossing a process boundary. */
53
+ class CommandError extends AkmError {
54
+ code;
55
+ kind;
56
+ /** Exit 70, a signal or a failed spawn: akm itself failed, not the request. */
57
+ internal;
58
+ constructor(message, code, exitCode) {
59
+ super(message);
60
+ this.code = code;
61
+ const kind = typeof exitCode === "number" ? KIND_BY_EXIT[exitCode] : undefined;
62
+ this.internal = kind === undefined;
63
+ this.kind = kind ?? "config";
64
+ }
65
+ hint() {
66
+ return undefined;
67
+ }
68
+ }
69
+ /** Exit zero is not sufficient evidence that the expected action returned a valid result. */
70
+ export function parseCommandResponse(stdout, expectedShape) {
71
+ try {
72
+ const result = JSON.parse(stdout);
73
+ if (!result ||
74
+ typeof result !== "object" ||
75
+ Array.isArray(result) ||
76
+ result.shape !== expectedShape ||
77
+ !Number.isInteger(result.schemaVersion) ||
78
+ result.ok === false)
79
+ throw new Error("Invalid command envelope");
80
+ return redactForUi(result);
81
+ }
82
+ catch {
83
+ throw new CommandError("AKM returned an invalid action response. State may already have changed. Reload the view before retrying; do not automatically repeat the action.", "INVALID_COMMAND_RESPONSE", 70);
84
+ }
85
+ }
86
+ /**
87
+ * HTTP status and code for a failed workspace action. A config refusal (a
88
+ * read-only bundle, exit 78) is something the user can address: `409`. Exit 70
89
+ * and anything unclassified stay `500`; the rest map as for proposal decisions.
90
+ */
91
+ export function classifyWorkspaceError(error) {
92
+ if (error instanceof CommandError && error.internal)
93
+ return { status: 500, code: error.code, message: error.message };
94
+ const classified = classifyProposalError(error);
95
+ return error instanceof AkmError && error.kind === "config" ? { ...classified, status: 409 } : classified;
96
+ }
97
+ /** Fixed UI actions use the real CLI, including feedback's argv-dependent tag validation and bundle install audits. */
98
+ async function command(args, input, userFeedback = false) {
99
+ const [bin, ...prefix] = resolveAkmInvocation().argv;
100
+ // Options go before a `--` that ends them, so a positional name may begin with a dash.
101
+ const end = args.indexOf("--");
102
+ const argv = end === -1 ? [...args, "--format=json"] : [...args.slice(0, end), "--format=json", ...args.slice(end)];
103
+ return new Promise((resolve, reject) => {
104
+ const child = execFile(bin, [...prefix, ...argv], {
105
+ encoding: "utf8",
106
+ maxBuffer: 16 * 1024 * 1024,
107
+ // An ambient event source (an improve or task run) wins, as for every akm child.
108
+ ...(userFeedback ? { env: { ...process.env, AKM_EVENT_SOURCE: process.env.AKM_EVENT_SOURCE || "user" } } : {}),
109
+ }, (error, stdout, stderr) => {
110
+ resetConfigCache();
111
+ if (error) {
112
+ const json = stderr.slice(stderr.lastIndexOf("\n{") + 1).trim();
113
+ let detail = {};
114
+ try {
115
+ detail = JSON.parse(json);
116
+ }
117
+ catch {
118
+ /* Keep the command's readable error. */
119
+ }
120
+ reject(new CommandError(redactUrlsInText(detail.error ?? (stderr.trim() || error.message)), detail.code ?? "COMMAND_FAILED", error.code));
121
+ return;
122
+ }
123
+ try {
124
+ resolve(parseCommandResponse(stdout, args[0] === "bundle" ? args[1] : args[0]));
125
+ }
126
+ catch (error) {
127
+ reject(error);
128
+ }
129
+ });
130
+ // Argument validation can close the child before a Markdown body finishes writing.
131
+ child.stdin?.on("error", (error) => {
132
+ if (error.code !== "EPIPE")
133
+ reject(error);
134
+ });
135
+ child.stdin?.end(input);
136
+ });
137
+ }
138
+ function settings(body) {
139
+ const result = mutateConfig((current) => {
140
+ let updated = current;
141
+ if (body.bundle !== undefined) {
142
+ const name = text(body, "bundle");
143
+ const entry = current.bundles?.[name];
144
+ if (!entry || !Object.hasOwn(current.bundles ?? {}, name))
145
+ throw new UsageError(`No configured bundle named "${name}".`);
146
+ for (const key of ["enabled", "writable"]) {
147
+ if (body[key] === undefined)
148
+ continue;
149
+ if (typeof body[key] !== "boolean")
150
+ throw new UsageError(`${key} must be a boolean.`);
151
+ updated = setConfigValue(updated, `bundles.${name}.${key}`, String(body[key]));
152
+ }
153
+ if (body.componentWritability !== undefined) {
154
+ for (const [id, writable] of Object.entries(object(body.componentWritability))) {
155
+ if (!Object.hasOwn(entry.components ?? {}, id) || id.includes(".") || typeof writable !== "boolean") {
156
+ throw new UsageError("Only existing components with a boolean writable value can be changed.");
157
+ }
158
+ updated = setConfigValue(updated, `bundles.${name}.components.${id}.writable`, String(writable));
159
+ }
160
+ }
161
+ }
162
+ for (const key of ["defaultBundle", "defaultWriteTarget"]) {
163
+ if (body[key] === undefined)
164
+ continue;
165
+ if (typeof body[key] !== "string")
166
+ throw new UsageError(`${key} must be a bundle name or empty.`);
167
+ const name = body[key].trim();
168
+ if (name && !Object.hasOwn(current.bundles ?? {}, name))
169
+ throw new UsageError(`No configured bundle named "${name}".`);
170
+ const source = name ? "" : getConfigValueSource(key);
171
+ if (source.startsWith("extends:"))
172
+ throw new UsageError(`Config key "${key}" is inherited from ${source} and cannot be unset here.`, "TARGET_NOT_UPDATABLE", `Override it locally with "akm config set ${key} <value>", or edit the base file directly.`);
173
+ updated = name ? setConfigValue(updated, key, name) : unsetConfigValue(updated, key);
174
+ }
175
+ // Only a write can drop credentials, so a request that changes nothing is never refused.
176
+ if (updated !== current)
177
+ preserveUnrelatedConfig(current);
178
+ return updated;
179
+ });
180
+ return { saved: result.written };
181
+ }
182
+ export async function handleWorkspaceAction(action, value) {
183
+ const body = object(value);
184
+ // `bundle update` writes only the lockfile, so it never passes config through the sanitizer.
185
+ if (action === "bundle-add" || action === "bundle-remove")
186
+ preserveUnrelatedConfig();
187
+ switch (action) {
188
+ case "feedback": {
189
+ const signal = text(body, "signal");
190
+ if (signal !== "positive" && signal !== "negative")
191
+ throw new UsageError("signal must be positive or negative.");
192
+ const args = ["feedback", positional(body, "ref"), `--${signal}`];
193
+ if (typeof body.reason === "string" && body.reason.trim())
194
+ args.push(`--reason=${body.reason.trim()}`);
195
+ if (body.tags !== undefined) {
196
+ if (!Array.isArray(body.tags) || body.tags.some((tag) => typeof tag !== "string"))
197
+ throw new UsageError("tags must be strings.");
198
+ for (const tag of body.tags)
199
+ args.push(`--tag=${tag}`);
200
+ }
201
+ return command(args, undefined, true);
202
+ }
203
+ case "bundle-add": {
204
+ const args = ["bundle", "add", positional(body, "source")];
205
+ if (typeof body.name === "string" && body.name.trim())
206
+ args.push(`--name=${body.name.trim()}`);
207
+ if (body.writable === true)
208
+ args.push("--writable");
209
+ return command(args);
210
+ }
211
+ case "bundle-remove": {
212
+ if (body.confirm !== true)
213
+ throw new UsageError("Confirm removal of this configured bundle first.");
214
+ const name = text(body, "bundle");
215
+ if (!Object.hasOwn(loadConfig().bundles ?? {}, name))
216
+ throw new UsageError(`No configured bundle named "${name}".`);
217
+ return command(["bundle", "remove", "--yes", "--", name]);
218
+ }
219
+ case "bundle-update": {
220
+ const name = text(body, "bundle");
221
+ if (!Object.hasOwn(loadConfig().bundles ?? {}, name))
222
+ throw new UsageError(`No configured bundle named "${name}".`);
223
+ return command(["bundle", "update", "--", name]);
224
+ }
225
+ case "bundle-settings":
226
+ return settings(body);
227
+ case "document-save": {
228
+ if (typeof body.content !== "string")
229
+ throw new UsageError("Markdown content is required.");
230
+ const content = body.content;
231
+ const revision = text(body, "revision");
232
+ const ref = text(body, "ref");
233
+ await assertWriteLeaseFree("ui-document-save");
234
+ // The writer takes the lease and compares `revision` with the live bytes inside it.
235
+ const source = await readDocumentSource(ref);
236
+ const identity = parseRefInput(source.ref);
237
+ const result = await writeMarkdownAsset({
238
+ type: source.type,
239
+ content,
240
+ name: identity.name,
241
+ fallbackPrefix: source.type,
242
+ target: identity.origin,
243
+ force: true,
244
+ expectedContentHash: revision,
245
+ });
246
+ if (!result.unchanged)
247
+ appendEvent({
248
+ eventType: source.type === "knowledge" ? "import" : "remember",
249
+ ref: result.ref,
250
+ metadata: { source: "ui", path: result.path, force: true },
251
+ });
252
+ return result;
253
+ }
254
+ case "knowledge-create": {
255
+ if (typeof body.content !== "string" || !body.content.trim())
256
+ throw new UsageError("Document content is required.");
257
+ const args = ["import", "-", `--name=${text(body, "name")}`, `--bundle=${text(body, "bundle")}`];
258
+ if (typeof body.path === "string" && body.path.trim())
259
+ args.push(`--path=${body.path.trim()}`);
260
+ await assertWriteLeaseFree("ui-knowledge-create");
261
+ return command(args, body.content);
262
+ }
263
+ }
264
+ }
@@ -77,7 +77,9 @@ const workflowStatusCommand = defineJsonCommand({
77
77
  const workflowListCommand = defineJsonCommand({
78
78
  meta: {
79
79
  name: "list",
80
- description: "List workflow runs in the current working scope",
80
+ description: "List workflow runs in the current working scope. A scheduled task runs from the scheduler's cwd, so its runs " +
81
+ "sit in another scope: get the run id from `akm task history <task-id> --limit 1` (detail.runId) and pass it " +
82
+ "to `akm workflow status <run-id>`, which works from any directory, or pass --all-scopes.",
81
83
  },
82
84
  args: {
83
85
  ref: { type: "string", description: "Filter to one workflow ref" },
@@ -192,6 +192,7 @@ const DOCUMENT_JSON_CARRIED_FIELDS = [
192
192
  "wikiRole",
193
193
  "sources",
194
194
  "evidenceSources",
195
+ "evidence",
195
196
  // #935: a workflow's step targets and a task's target, read as declared links.
196
197
  "uses",
197
198
  // D2 (#730): the OKF v0.2 provenance `promoteProposal` stamps onto AKM-native
@@ -73,6 +73,7 @@ import fs from "node:fs";
73
73
  import path from "node:path";
74
74
  import { parse as parseYaml } from "yaml";
75
75
  import { parseFrontmatter, parseFrontmatterBlock } from "../../asset/frontmatter.js";
76
+ import { markdownLinkTargets } from "../../asset/markdown-link-targets.js";
76
77
  import { toPosix } from "../../common.js";
77
78
  import { hashContent, nonEmptyString } from "./shared.js";
78
79
  /** A wiki is a single-component bundle; its one component is conventionally `main` (recognition golden). */
@@ -169,13 +170,10 @@ function resolveXref(xref, bundleId) {
169
170
  */
170
171
  function resolveBodyLinks(body, fileRelPath) {
171
172
  const dir = path.posix.dirname(toPosix(fileRelPath));
172
- const linkRe = /\[[^\]]*\]\(([^)]+)\)/g;
173
173
  const out = [];
174
174
  const seen = new Set();
175
- let match;
176
- // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
177
- while ((match = linkRe.exec(body)) !== null) {
178
- let target = match[1].trim();
175
+ for (const link of markdownLinkTargets(body, false)) {
176
+ let target = link.trim();
179
177
  const wsIdx = target.search(/\s/);
180
178
  if (wsIdx >= 0)
181
179
  target = target.slice(0, wsIdx);
@@ -31,6 +31,7 @@
31
31
  import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import { parseFrontmatter } from "../../asset/frontmatter.js";
34
+ import { markdownLinkTargets } from "../../asset/markdown-link-targets.js";
34
35
  import { isRecord, toPosix } from "../../common.js";
35
36
  import { hashContent, nonEmptyString, RESERVED_FILES, readTags, runBaseValidateChecks } from "./shared.js";
36
37
  /** v0.2 frontmatter keys consumed into first-class fields below (§0.1) — excluded from the generic `documentJson` extras fold alongside the v0.1 five, so nothing is duplicated between a first-class field and the opaque extras bag. */
@@ -142,24 +143,10 @@ function isReservedFileName(name) {
142
143
  */
143
144
  export function resolveOkfLinks(body, fileRelPath) {
144
145
  const dir = path.posix.dirname(toPosix(fileRelPath));
145
- const definitions = new Map();
146
- for (const match of body.matchAll(/^\s*\[([^\]]+)\]:\s*(\S+)/gm)) {
147
- definitions.set(match[1].trim().toLowerCase(), match[2]);
148
- }
149
- const candidates = [];
150
- for (const match of body.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
151
- candidates.push({ index: match.index, target: match[1] });
152
- }
153
- for (const match of body.matchAll(/(?<!!)\[[^\]]*\]\[([^\]]+)\]/g)) {
154
- const target = definitions.get(match[1].trim().toLowerCase());
155
- if (target)
156
- candidates.push({ index: match.index, target });
157
- }
158
- candidates.sort((a, b) => a.index - b.index);
159
146
  const out = [];
160
147
  const seen = new Set();
161
- for (const candidate of candidates) {
162
- let target = candidate.target.trim();
148
+ for (const link of markdownLinkTargets(body, true)) {
149
+ let target = link.trim();
163
150
  // Drop an optional markdown link title: `[x](/a.md "Title")`.
164
151
  const wsIdx = target.search(/\s/);
165
152
  if (wsIdx >= 0)
@@ -32,12 +32,14 @@ import { parseFrontmatterBlock, replaceFrontmatterLine, spliceFrontmatterLine }
32
32
  * intended mapping; when it does not (a `type` value that spans several lines,
33
33
  * a flow-style `{…}` block), the document is re-serialized instead, as it
34
34
  * always used to be. A document with no frontmatter block gets a new one;
35
- * malformed YAML throws.
35
+ * malformed YAML throws. An inserted or re-serialized block uses CRLF when
36
+ * every line break in the document is CRLF, and LF otherwise.
36
37
  */
37
38
  export function ensureAkmMarkdownType(content, type, now = new Date()) {
39
+ const eol = documentLineEnding(content);
38
40
  const block = parseFrontmatterBlock(content);
39
41
  if (!block) {
40
- return `---\n${serializeFrontmatter({ type, updated: localDateStamp(now) })}\n---\n${content}`;
42
+ return (`---\n${serializeFrontmatter({ type, updated: localDateStamp(now) })}\n---\n`.replaceAll("\n", eol) + content);
41
43
  }
42
44
  let parsed;
43
45
  try {
@@ -74,7 +76,21 @@ export function ensureAkmMarkdownType(content, type, now = new Date()) {
74
76
  const next = { type, ...rest };
75
77
  if (needsUpdated)
76
78
  next.updated = updated;
77
- return `---\n${serializeFrontmatter(next)}\n---\n${block.content}`;
79
+ return `---\n${serializeFrontmatter(next)}\n---\n`.replaceAll("\n", eol) + block.content;
80
+ }
81
+ /**
82
+ * The exact text `writeAssetToSource` writes for an AKM Markdown asset: the
83
+ * conformant document from {@link ensureAkmMarkdownType}, ending in a line
84
+ * break in the document's own line-ending style.
85
+ */
86
+ export function normalizeAkmMarkdown(content, type, now = new Date()) {
87
+ const authored = ensureAkmMarkdownType(content, type, now);
88
+ return authored.endsWith("\n") ? authored : authored + documentLineEnding(authored);
89
+ }
90
+ /** CRLF only when every line break is CRLF; LF for LF-only, mixed or single-line text. */
91
+ function documentLineEnding(text) {
92
+ const breaks = text.split("\n").length - 1;
93
+ return breaks > 0 && text.split("\r\n").length - 1 === breaks ? "\r\n" : "\n";
78
94
  }
79
95
  /** True when the frontmatter of `text` parses to exactly `intended`. */
80
96
  function parsesTo(text, intended) {
@@ -0,0 +1,43 @@
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 { Marked } from "marked";
5
+ // A private instance keeps unrelated consumers' global Marked options out of indexing.
6
+ const markdown = new Marked({ gfm: true });
7
+ /**
8
+ * Actual document links, in authored order. The tokenizer owns code regions,
9
+ * escapes and nested containers; the adapter still owns its supported syntax
10
+ * and path resolution. In particular, shortcut/collapsed references and wiki
11
+ * links are not new indexed syntax. Inline images retain their existing treatment.
12
+ */
13
+ export function markdownLinkTargets(body, references) {
14
+ const tokens = markdown.lexer(body);
15
+ const definitions = new Map();
16
+ if (references) {
17
+ markdown.walkTokens(tokens, (token) => {
18
+ if (token.type !== "def")
19
+ return;
20
+ const definition = /^\s*\[([^\]]+)\]:\s*(\S+)/.exec(token.raw);
21
+ if (definition)
22
+ definitions.set(definition[1].trim().toLowerCase(), definition[2]);
23
+ });
24
+ }
25
+ const targets = [];
26
+ markdown.walkTokens(tokens, (token) => {
27
+ if (token.type !== "link" && token.type !== "image")
28
+ return;
29
+ const inline = /^!?\[[\s\S]*\]\(/.test(token.raw);
30
+ if (inline) {
31
+ // Angle-wrapped destinations were outside the adapters' existing subset.
32
+ const angled = /\]\(\s*<[^>]*>(?:\s+["'(][\s\S]*["')])?\s*\)$/.test(token.raw);
33
+ if (!angled)
34
+ targets.push(token.href);
35
+ return;
36
+ }
37
+ const reference = references && token.type === "link" ? /^\[[\s\S]*\]\[([^\]]+)\]$/.exec(token.raw) : null;
38
+ const target = reference ? definitions.get(reference[1].trim().toLowerCase()) : undefined;
39
+ if (target)
40
+ targets.push(target);
41
+ });
42
+ return targets;
43
+ }
@@ -16,7 +16,8 @@ export function markdownHeadingSlug(heading) {
16
16
  export function markdownFragmentSlugs(content) {
17
17
  return uniqueHeadingSlugs(parseMarkdownToc(content).headings).filter(Boolean);
18
18
  }
19
- function uniqueHeadingSlugs(headings) {
19
+ /** One slug per heading, in order; a heading with no sluggable text gets "". */
20
+ export function uniqueHeadingSlugs(headings) {
20
21
  const used = new Set();
21
22
  return headings.map((heading) => {
22
23
  const base = markdownHeadingSlug(heading.text);
@@ -36,15 +37,22 @@ export function parseMarkdownToc(content) {
36
37
  const headings = [];
37
38
  const parsed = parseFrontmatter(content);
38
39
  const start = parsed.frontmatter ? parsed.bodyStartLine - 1 : 0;
39
- let inFence = false;
40
+ // Fenced code blocks follow CommonMark, so headings inside them are skipped:
41
+ // a fence opens with 3+ backticks or tildes indented at most 3 spaces (a
42
+ // backtick fence's info string has no backtick) and closes only on a line of
43
+ // the same character, at least as long, followed by nothing but whitespace.
44
+ let fence;
40
45
  for (let i = start; i < lines.length; i++) {
41
- // Track fenced code blocks (``` or ~~~) so headings inside them are skipped.
42
- if (/^\s*(`{3,}|~{3,})/.test(lines[i])) {
43
- inFence = !inFence;
46
+ const [, run = "", rest = ""] = lines[i].match(/^ {0,3}(`{3,}|~{3,})(.*)$/) ?? [];
47
+ if (fence) {
48
+ if (run[0] === fence.char && run.length >= fence.length && !rest.trim())
49
+ fence = undefined;
44
50
  continue;
45
51
  }
46
- if (inFence)
52
+ if (run && !(run[0] === "`" && rest.includes("`"))) {
53
+ fence = { char: run[0], length: run.length };
47
54
  continue;
55
+ }
48
56
  const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
49
57
  if (match) {
50
58
  headings.push({
@@ -752,9 +752,10 @@ export function schedulerSourceIdFor(config, bundleId) {
752
752
  * user knows their key was dropped and how to provide it at runtime (#474).
753
753
  * Previously the strip was silent — a user invoking `akm setup --from <file>
754
754
  * --yes` with an `apiKey` field expected persistence and got a wiped config
755
- * with no feedback.
755
+ * with no feedback. `quiet` skips the warnings for a caller that only asks
756
+ * whether a write would drop anything and writes nothing itself.
756
757
  */
757
- export function sanitizeConfigForWrite(config) {
758
+ export function sanitizeConfigForWrite(config, options) {
758
759
  const sanitized = { ...config };
759
760
  const stripped = [];
760
761
  if (config.embedding?.apiKey !== undefined) {
@@ -787,7 +788,7 @@ export function sanitizeConfigForWrite(config) {
787
788
  }
788
789
  sanitized.engines = engines;
789
790
  }
790
- if (stripped.length > 0) {
791
+ if (stripped.length > 0 && !options?.quiet) {
791
792
  warn(`Config sanitizer dropped API key(s) before writing to disk:\n - ${stripped.join("\n - ")}\n\nakm does not persist API keys to config.json. Set the listed environment variables to provide them at runtime, or use \`\${VAR}\` references in your config to defer lookup. See docs/reference/data-and-telemetry.md.`);
792
793
  }
793
794
  if (config.registries) {
@@ -800,7 +801,8 @@ export function sanitizeConfigForWrite(config) {
800
801
  });
801
802
  if (droppedRegistries.length > 0) {
802
803
  sanitized.registries = registries;
803
- warn(`Config sanitizer dropped registry entr${droppedRegistries.length === 1 ? "y" : "ies"} with URL credentials before writing to disk:\n - ${droppedRegistries.join("\n - ")}\n\nRegistry URLs must be credential-free; configure a credential-free HTTPS endpoint.`);
804
+ if (!options?.quiet)
805
+ warn(`Config sanitizer dropped registry entr${droppedRegistries.length === 1 ? "y" : "ies"} with URL credentials before writing to disk:\n - ${droppedRegistries.join("\n - ")}\n\nRegistry URLs must be credential-free; configure a credential-free HTTPS endpoint.`);
804
806
  }
805
807
  }
806
808
  return sanitized;
@@ -3,18 +3,82 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /** The line of `src/assets/stash-skeleton/README.md` that reaches curate verbatim as a query. */
5
5
  const STASH_README_LINE = "This is an **AKM stash** — a structured knowledge repository that stores reusable";
6
+ /** Exact normalized whole-message phrases; added task text must still retrieve. */
7
+ const SOCIAL_INPUTS = new Set([
8
+ "thanks",
9
+ "thanks that worked",
10
+ "thank you",
11
+ "thank you that worked",
12
+ "ok",
13
+ "ok sounds good",
14
+ "ok go ahead",
15
+ "ok sounds good go ahead",
16
+ "okay",
17
+ "okay sounds good",
18
+ "okay go ahead",
19
+ "okay sounds good go ahead",
20
+ "hi",
21
+ "hi are you still there",
22
+ "hi ready when you are",
23
+ "hello",
24
+ "hello are you still there",
25
+ "hello ready when you are",
26
+ "hey",
27
+ "hey are you still there",
28
+ "hey ready when you are",
29
+ "good morning",
30
+ "good morning are you still there",
31
+ "good morning ready when you are",
32
+ "good afternoon",
33
+ "good afternoon are you still there",
34
+ "good afternoon ready when you are",
35
+ "good evening",
36
+ "good evening are you still there",
37
+ "good evening ready when you are",
38
+ "yes",
39
+ "yep",
40
+ "y",
41
+ "no",
42
+ "lgtm",
43
+ "ok do it",
44
+ "do it",
45
+ "go ahead",
46
+ "sounds good",
47
+ "looks good",
48
+ "continue",
49
+ "continue please",
50
+ "perfect",
51
+ "nice",
52
+ "cool",
53
+ "thx",
54
+ "thanks so much",
55
+ "great thanks",
56
+ "ok thanks",
57
+ "ok got it",
58
+ "hi there",
59
+ "hello there",
60
+ "hey thanks a lot",
61
+ ]);
6
62
  /**
7
63
  * What the (trimmed) curate input is when it is not a task, else undefined.
8
64
  * Harness and tool envelopes (`<task-notification>…`, `<system-reminder>…`,
9
65
  * `<cross-session-message …>…`) start with a tag and close one, and the stash
10
- * README line arrives verbatim; on the retrieval suite neither shape occurs in
11
- * a real query. Length is not a signal: prompts over 2,000 characters found
12
- * relevant assets at about the rate of shorter long prompts.
66
+ * README line arrives verbatim. Only the explicit normalized phrases
67
+ * above are withheld; this is not general conversational intent detection.
68
+ * Length is not a signal: prompts over 2,000 characters found relevant assets
69
+ * at about the rate of shorter long prompts.
13
70
  */
14
71
  export function nonTaskInput(query) {
15
72
  if (query.startsWith("<") && query.includes("</"))
16
73
  return "a harness or tool envelope";
17
74
  if (query === STASH_README_LINE)
18
75
  return "the akm stash README boilerplate";
76
+ const conversational = query
77
+ .toLowerCase()
78
+ .replace(/[,.!?]+/g, " ")
79
+ .replace(/\s+/g, " ")
80
+ .trim();
81
+ if (SOCIAL_INPUTS.has(conversational))
82
+ return "an exact conversational phrase";
19
83
  return undefined;
20
84
  }
@@ -32,7 +32,7 @@ import { lockContentRootFor } from "../integrations/lockfile.js";
32
32
  import { GitStashPushError, getCachePaths, inspectGitUpstream, isGitBackedStash, parseGitRepoUrl, runGit, saveGitStash, } from "../sources/providers/git.js";
33
33
  import { listIgnoredExactPaths } from "../sources/providers/git-stash.js";
34
34
  import { detectAdapterId } from "./adapter/detect-adapter.js";
35
- import { ensureAkmMarkdownType } from "./asset/akm-markdown.js";
35
+ import { normalizeAkmMarkdown } from "./asset/akm-markdown.js";
36
36
  import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
37
37
  import { conceptIdFromTypeName, displayRef } from "./asset/resolve-ref.js";
38
38
  import { deriveBundleId } from "./bundle-id.js";
@@ -236,7 +236,7 @@ export async function writeAssetToSource(source, config, ref, content) {
236
236
  assertSupportedKind(source);
237
237
  assertAkmAssetWrite(source);
238
238
  const filePath = resolveAssetFilePath(source, ref);
239
- const authored = filePath.toLowerCase().endsWith(".md") ? ensureAkmMarkdownType(content, ref.type) : content;
239
+ const authored = filePath.toLowerCase().endsWith(".md") ? normalizeAkmMarkdown(content, ref.type) : content;
240
240
  const normalized = authored.endsWith("\n") ? authored : `${authored}\n`;
241
241
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
242
242
  // Atomic: sibling temp file, fdatasync, rename — a crash or a full disk never
@@ -138,7 +138,11 @@ export async function withAssetMutationLease(purpose, run) {
138
138
  return run();
139
139
  const context = inherited ?? new Set();
140
140
  const execute = async () => {
141
- const lease = await acquireAssetMutationLease({ purpose });
141
+ const lease = await acquireAssetMutationLease({
142
+ purpose,
143
+ // #956: say who is in the way instead of waiting silently, as the sync path does.
144
+ onWait: ({ waitedMs }) => warn(`waiting for ${describeAssetMutationHolder(lockPath)} — ${Math.round(waitedMs / 1000)}s`),
145
+ });
142
146
  if (!lease)
143
147
  throw new Error(`asset mutation lease unavailable for ${purpose}`);
144
148
  context.add(lockPath);