akm-cli 0.10.261009-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 (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/STABILITY.md +15 -4
  3. package/dist/assets/prompts/memory-infer-system.md +1 -1
  4. package/dist/assets/prompts/memory-infer-user.md +3 -2
  5. package/dist/assets/ui/app.js +59 -2
  6. package/dist/assets/ui/asset.js +71 -6
  7. package/dist/assets/ui/bundles.js +80 -3
  8. package/dist/assets/ui/diff-view.js +1 -1
  9. package/dist/assets/ui/editor.js +103 -0
  10. package/dist/assets/ui/index.html +2 -0
  11. package/dist/assets/ui/lib.js +51 -1
  12. package/dist/assets/ui/markdown.js +86 -19
  13. package/dist/assets/ui/search.js +25 -5
  14. package/dist/assets/ui/styles.css +70 -0
  15. package/dist/commands/health/improve-metrics.js +11 -1
  16. package/dist/commands/health/md-report.js +3 -3
  17. package/dist/commands/improve/loop-stages.js +2 -2
  18. package/dist/commands/read/curate.js +1 -1
  19. package/dist/commands/read/knowledge.js +109 -77
  20. package/dist/commands/sources/bundle-cli.js +4 -6
  21. package/dist/commands/sources/installed-stashes.js +10 -19
  22. package/dist/commands/ui/api.js +95 -5
  23. package/dist/commands/ui/document-source.js +51 -0
  24. package/dist/commands/ui/git-read.js +18 -2
  25. package/dist/commands/ui/proposal-actions.js +5 -3
  26. package/dist/commands/ui/server.js +25 -13
  27. package/dist/commands/ui/ui-cli.js +3 -3
  28. package/dist/commands/ui/workspace-actions.js +264 -0
  29. package/dist/commands/workflow-cli.js +3 -1
  30. package/dist/core/asset/akm-markdown.js +19 -3
  31. package/dist/core/asset/markdown.js +14 -6
  32. package/dist/core/config/config.js +6 -4
  33. package/dist/core/non-task-input.js +67 -3
  34. package/dist/core/write-source.js +2 -2
  35. package/dist/indexer/index-writer-lock.js +5 -1
  36. package/dist/indexer/passes/memory-inference.js +28 -6
  37. package/dist/llm/memory-infer.js +23 -14
  38. package/dist/scripts/akm-migrate-node.js +18 -10
  39. package/dist/scripts/akm-migrate.js +18 -10
  40. package/dist/workflows/exec/environment.js +4 -8
  41. package/dist/workflows/parser.js +13 -4
  42. package/docs/architecture/0.10-process-rules.md +5 -3
  43. package/docs/architecture/internals/storage-locations.md +1 -1
  44. package/docs/guides/author-workflows.md +78 -28
  45. package/docs/guides/bundles.md +16 -2
  46. package/docs/reference/cli.md +39 -16
  47. package/docs/reference/env-and-secrets.md +11 -0
  48. package/docs/reference/ui-api.md +84 -8
  49. package/docs/reference/workflow-schema.md +63 -32
  50. package/package.json +1 -1
@@ -10,7 +10,7 @@
10
10
  * `Host` header must be the address the server is bound to, which stops a web
11
11
  * page from reaching it through DNS rebinding. Static UI files are served
12
12
  * without the token and embed no data. Only GET (and HEAD) is accepted, except
13
- * for the three proposal writes (accept, reject, accept several): a POST to one
13
+ * for explicit proposal and workspace actions: a POST to one
14
14
  * of those must carry the token in the `X-Akm-Ui-Token` header (never the
15
15
  * query string), a JSON content type and an `Origin` that is this server's own,
16
16
  * so another web page cannot make the browser send one.
@@ -27,6 +27,7 @@ import chartsJs from "../../assets/ui/charts.js" with { type: "text" };
27
27
  import configJs from "../../assets/ui/config.js" with { type: "text" };
28
28
  import diffJs from "../../assets/ui/diff.js" with { type: "text" };
29
29
  import diffViewJs from "../../assets/ui/diff-view.js" with { type: "text" };
30
+ import editorJs from "../../assets/ui/editor.js" with { type: "text" };
30
31
  import indexHtml from "../../assets/ui/index.html" with { type: "text" };
31
32
  import libJs from "../../assets/ui/lib.js" with { type: "text" };
32
33
  import markdownJs from "../../assets/ui/markdown.js" with { type: "text" };
@@ -40,9 +41,12 @@ import { getDirname } from "../../runtime.js";
40
41
  import { handleApiRequest, NO_SUCH_ROUTE } from "./api.js";
41
42
  import { classifyProposalError, handleWriteRequest, matchWriteRoute } from "./proposal-actions.js";
42
43
  import { scrubUrlCredentials } from "./redact.js";
44
+ import { classifyWorkspaceError, handleWorkspaceAction, matchWorkspaceAction, } from "./workspace-actions.js";
43
45
  export const TOKEN_HEADER = "x-akm-ui-token";
44
46
  /** Largest request body a write route reads. */
45
47
  export const MAX_BODY_BYTES = 64 * 1024;
48
+ /** Largest body of a route that carries a whole Markdown document (`/api/asset/save`, `/api/knowledge/create`). */
49
+ export const MAX_DOCUMENT_BODY_BYTES = 8 * 1024 * 1024;
46
50
  const UI_DIR = path.join(getDirname(import.meta.url), "../../assets/ui");
47
51
  /**
48
52
  * The UI files embedded at build time. Disk stays primary (an edit shows
@@ -62,6 +66,7 @@ export const EMBEDDED_UI = {
62
66
  "config.js": configJs,
63
67
  "diff-view.js": diffViewJs,
64
68
  "diff.js": diffJs,
69
+ "editor.js": editorJs,
65
70
  "lib.js": libJs,
66
71
  "markdown.js": markdownJs,
67
72
  "overview.js": overviewJs,
@@ -148,11 +153,11 @@ function originAllowed(origin, hostHeader) {
148
153
  function isJsonContentType(header) {
149
154
  return (header ?? "").split(";")[0]?.trim().toLowerCase() === "application/json";
150
155
  }
151
- /** The request body as text, or `null` when it is larger than {@link MAX_BODY_BYTES}. */
152
- function readBody(req) {
156
+ /** The request body as text, or `null` when it is larger than `limit` bytes. */
157
+ function readBody(req, limit) {
153
158
  return new Promise((resolve, reject) => {
154
159
  const declared = Number(req.headers["content-length"]);
155
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
160
+ if (Number.isFinite(declared) && declared > limit) {
156
161
  resolve(null);
157
162
  return;
158
163
  }
@@ -161,7 +166,7 @@ function readBody(req) {
161
166
  let tooLarge = false;
162
167
  req.on("data", (chunk) => {
163
168
  size += chunk.length;
164
- if (size > MAX_BODY_BYTES)
169
+ if (size > limit)
165
170
  tooLarge = true;
166
171
  else
167
172
  chunks.push(chunk);
@@ -237,7 +242,8 @@ export async function startUiServer(options = {}) {
237
242
  }
238
243
  const url = new URL(req.url ?? "/", "http://akm-ui.invalid");
239
244
  const isApi = url.pathname === "/api" || url.pathname.startsWith("/api/");
240
- const writeRoute = req.method === "POST" ? matchWriteRoute(url.pathname) : null;
245
+ const workspace = req.method === "POST" ? matchWorkspaceAction(url.pathname) : null;
246
+ const writeRoute = req.method === "POST" ? (workspace ? { workspace } : matchWriteRoute(url.pathname)) : null;
241
247
  if (writeRoute) {
242
248
  await handleWrite(req, res, writeRoute);
243
249
  return;
@@ -255,7 +261,7 @@ export async function startUiServer(options = {}) {
255
261
  }
256
262
  }
257
263
  if (!readMethod) {
258
- sendError(req, res, 405, "METHOD_NOT_ALLOWED", "Only GET is accepted here. akm ui writes only through POST /api/proposals/:id/accept, /reject and /api/proposals/accept.", {
264
+ sendError(req, res, 405, "METHOD_NOT_ALLOWED", "Only GET is accepted here. Use an explicit UI action to make changes.", {
259
265
  Allow: "GET, HEAD",
260
266
  });
261
267
  return;
@@ -271,7 +277,9 @@ export async function startUiServer(options = {}) {
271
277
  }
272
278
  catch (error) {
273
279
  const code = error instanceof AkmError ? error.code : "INTERNAL_ERROR";
274
- sendError(req, res, statusFor(error), code, error instanceof Error ? error.message : String(error));
280
+ // The editor's source read refuses what a save would refuse, with the same status.
281
+ const status = url.pathname === "/api/asset/source" ? classifyWorkspaceError(error).status : statusFor(error);
282
+ sendError(req, res, status, code, error instanceof Error ? error.message : String(error));
275
283
  }
276
284
  return;
277
285
  }
@@ -288,7 +296,7 @@ export async function startUiServer(options = {}) {
288
296
  });
289
297
  res.end(req.method === "HEAD" ? undefined : asset.body);
290
298
  }
291
- /** The three proposal writes. Every check runs before the body is read. */
299
+ /** Explicit write actions. Every check runs before the body is read. */
292
300
  async function handleWrite(req, res, route) {
293
301
  // The header only: a token in the URL ends up in history and logs, and a link can carry it.
294
302
  const given = req.headers[TOKEN_HEADER];
@@ -306,9 +314,11 @@ export async function startUiServer(options = {}) {
306
314
  sendError(req, res, 415, "UNSUPPORTED_MEDIA_TYPE", "Writes must be sent as Content-Type: application/json.");
307
315
  return;
308
316
  }
309
- const text = await readBody(req);
317
+ const document = "workspace" in route && (route.workspace === "document-save" || route.workspace === "knowledge-create");
318
+ const limit = document ? MAX_DOCUMENT_BODY_BYTES : MAX_BODY_BYTES;
319
+ const text = await readBody(req, limit);
310
320
  if (text === null) {
311
- sendError(req, res, 413, "BODY_TOO_LARGE", `The request body is larger than ${MAX_BODY_BYTES} bytes.`, {
321
+ sendError(req, res, 413, "BODY_TOO_LARGE", `The request body is larger than ${limit} bytes.`, {
312
322
  Connection: "close",
313
323
  });
314
324
  return;
@@ -322,11 +332,13 @@ export async function startUiServer(options = {}) {
322
332
  return;
323
333
  }
324
334
  try {
325
- const data = await handleWriteRequest(route, body);
335
+ const data = "workspace" in route
336
+ ? await handleWorkspaceAction(route.workspace, body)
337
+ : await handleWriteRequest(route, body);
326
338
  sendJson(req, res, 200, { ok: true, data: scrubUrlCredentials(data) });
327
339
  }
328
340
  catch (error) {
329
- const { status, code, message } = classifyProposalError(error);
341
+ const { status, code, message } = "workspace" in route ? classifyWorkspaceError(error) : classifyProposalError(error);
330
342
  sendError(req, res, status, code, message);
331
343
  }
332
344
  }
@@ -2,7 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * `akm ui` — a local web UI, read-only apart from accepting and rejecting proposals, over the same data the CLI reports:
5
+ * `akm ui` — a local web UI over the same data and explicit operations the CLI offers:
6
6
  * health and metrics, bundles, config, the proposal queue, recent changes and
7
7
  * search. See `docs/reference/ui-api.md` for the API the page reads.
8
8
  */
@@ -23,7 +23,7 @@ function parsePort(raw) {
23
23
  export const uiCommand = defineJsonCommand({
24
24
  meta: {
25
25
  name: "ui",
26
- description: "Experimental: serve a local web UI (health, bundles, config, proposals, recent changes, search) and open it in your browser. It is read-only except that you can accept and reject proposals in it. The URL carries a per-launch token. Press Ctrl-C to stop.",
26
+ description: "Experimental: serve a local web UI for browsing, feedback, bundle management, Markdown authoring and proposal decisions. Browsing is read-only; explicit actions use existing AKM operations. The URL carries a per-launch token. Press Ctrl-C to stop.",
27
27
  },
28
28
  args: {
29
29
  port: { type: "string", description: "Port to listen on (default: a random free port)" },
@@ -40,7 +40,7 @@ export const uiCommand = defineJsonCommand({
40
40
  const host = args.host?.trim() || "127.0.0.1";
41
41
  const server = await startUiServer({ host, port: parsePort(args.port) });
42
42
  if (!isLoopbackBind(host)) {
43
- warn(`[ui] Listening on ${host}, not just this machine: anyone who can reach it and has the token can read your stash, config and proposals. Keep the URL private.`);
43
+ warn(`[ui] Listening on ${host}, not just this machine: anyone who can reach it and has the token can read your data and use the UI's write actions. Keep the URL private.`);
44
44
  }
45
45
  // The URL is the command's output, so it goes to stdout like any other result.
46
46
  process.stdout.write(`akm ui: ${server.url}\n`);
@@ -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" },
@@ -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) {
@@ -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