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.
- package/CHANGELOG.md +97 -0
- package/STABILITY.md +25 -8
- package/dist/api.d.ts +6 -0
- package/dist/api.js +12 -1
- package/dist/assets/prompts/consolidate-pair-check.md +2 -0
- package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
- package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
- package/dist/assets/prompts/consolidate-pair.md +4 -12
- 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/health.js +1 -2
- package/dist/commands/improve/consolidate/pair-pass.js +59 -31
- package/dist/commands/improve/extract.js +18 -12
- package/dist/commands/improve/loop-stages.js +2 -2
- package/dist/commands/improve/memory/memory-belief.js +2 -2
- package/dist/commands/proposal/repository.js +44 -13
- package/dist/commands/read/curate.js +78 -19
- package/dist/commands/read/knowledge.js +109 -77
- package/dist/commands/read/search-cli.js +25 -11
- package/dist/commands/read/search.js +3 -3
- package/dist/commands/read/show.js +47 -1
- 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/adapter/adapters/akm-adapter.js +1 -0
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
- package/dist/core/adapter/adapters/okf-adapter.js +3 -16
- package/dist/core/asset/akm-markdown.js +19 -3
- package/dist/core/asset/markdown-link-targets.js +43 -0
- 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/indexer/passes/metadata.js +17 -0
- package/dist/indexer/scan/doc-to-entry.js +1 -0
- package/dist/indexer/search/db-search.js +16 -7
- package/dist/indexer/search/search-hit-enrichers.js +3 -3
- package/dist/llm/memory-infer.js +23 -14
- package/dist/output/shapes/curate.js +3 -1
- package/dist/output/shapes/helpers.js +6 -0
- package/dist/output/text/show-format.js +7 -0
- package/dist/scripts/akm-migrate-node.js +1820 -329
- package/dist/scripts/akm-migrate.js +1820 -329
- package/dist/storage/repositories/index-entries-repository.js +16 -0
- package/dist/storage/repositories/index-fts-repository.js +28 -6
- package/dist/storage/repositories/index-vec-repository.js +19 -3
- package/dist/workflows/exec/environment.js +4 -8
- package/dist/workflows/parser.js +13 -4
- package/docs/architecture/0.10-process-rules.md +9 -7
- package/docs/architecture/architecture.md +4 -0
- package/docs/architecture/internals/storage-locations.md +1 -1
- package/docs/architecture/persisted-data-compat.md +1 -1
- package/docs/guides/author-workflows.md +78 -28
- package/docs/guides/bundles.md +16 -2
- package/docs/reference/api.md +19 -1
- package/docs/reference/cli.md +90 -18
- package/docs/reference/env-and-secrets.md +11 -0
- package/docs/reference/memory.md +7 -6
- package/docs/reference/ui-api.md +84 -8
- package/docs/reference/workflow-schema.md +63 -32
- 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
|
-
|
|
176
|
-
|
|
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
|
|
162
|
-
let target =
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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({
|
|
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);
|