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.
- package/CHANGELOG.md +36 -0
- package/STABILITY.md +15 -4
- package/dist/assets/prompts/memory-infer-system.md +1 -1
- package/dist/assets/prompts/memory-infer-user.md +3 -2
- package/dist/assets/ui/app.js +59 -2
- package/dist/assets/ui/asset.js +71 -6
- package/dist/assets/ui/bundles.js +80 -3
- package/dist/assets/ui/diff-view.js +1 -1
- package/dist/assets/ui/editor.js +103 -0
- package/dist/assets/ui/index.html +2 -0
- package/dist/assets/ui/lib.js +51 -1
- package/dist/assets/ui/markdown.js +86 -19
- package/dist/assets/ui/search.js +25 -5
- package/dist/assets/ui/styles.css +70 -0
- package/dist/commands/health/improve-metrics.js +11 -1
- package/dist/commands/health/md-report.js +3 -3
- package/dist/commands/improve/loop-stages.js +2 -2
- package/dist/commands/read/curate.js +1 -1
- package/dist/commands/read/knowledge.js +109 -77
- package/dist/commands/sources/bundle-cli.js +4 -6
- package/dist/commands/sources/installed-stashes.js +10 -19
- package/dist/commands/ui/api.js +95 -5
- package/dist/commands/ui/document-source.js +51 -0
- package/dist/commands/ui/git-read.js +18 -2
- package/dist/commands/ui/proposal-actions.js +5 -3
- package/dist/commands/ui/server.js +25 -13
- package/dist/commands/ui/ui-cli.js +3 -3
- package/dist/commands/ui/workspace-actions.js +264 -0
- package/dist/commands/workflow-cli.js +3 -1
- package/dist/core/asset/akm-markdown.js +19 -3
- package/dist/core/asset/markdown.js +14 -6
- package/dist/core/config/config.js +6 -4
- package/dist/core/non-task-input.js +67 -3
- package/dist/core/write-source.js +2 -2
- package/dist/indexer/index-writer-lock.js +5 -1
- package/dist/indexer/passes/memory-inference.js +28 -6
- package/dist/llm/memory-infer.js +23 -14
- package/dist/scripts/akm-migrate-node.js +18 -10
- package/dist/scripts/akm-migrate.js +18 -10
- package/dist/workflows/exec/environment.js +4 -8
- package/dist/workflows/parser.js +13 -4
- package/docs/architecture/0.10-process-rules.md +5 -3
- package/docs/architecture/internals/storage-locations.md +1 -1
- package/docs/guides/author-workflows.md +78 -28
- package/docs/guides/bundles.md +16 -2
- package/docs/reference/cli.md +39 -16
- package/docs/reference/env-and-secrets.md +11 -0
- package/docs/reference/ui-api.md +84 -8
- package/docs/reference/workflow-schema.md +63 -32
- 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
|
|
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
|
|
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 >
|
|
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 >
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 ${
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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") ?
|
|
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
|