@rse/ase 0.9.5 → 0.9.7

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.
@@ -0,0 +1,331 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ import picomatch from "picomatch";
9
+ import { isScalar } from "yaml";
10
+ import { z } from "zod";
11
+ import { Config, configSchema } from "./ase-config.js";
12
+ import { Task } from "./ase-task.js";
13
+ /* the recognized artifact kinds, in descending precedence order;
14
+ "othr" is the implicit catch-all and is always resolved last */
15
+ export const artifactKinds = ["spec", "arch", "code", "docs", "infr", "othr"];
16
+ /* the five configured kinds (i.e. all kinds except the implicit "othr") */
17
+ const configuredKinds = ["spec", "arch", "code", "docs", "infr"];
18
+ /* reusable functionality: resolve artifact kinds to project-relative
19
+ file lists, driven by the "project.artifact.<kind>" configuration */
20
+ export class Artifact {
21
+ /* validate a requested kind against the known set */
22
+ static validateKind(kind) {
23
+ if (!artifactKinds.includes(kind))
24
+ throw new Error(`artifact: unknown kind "${kind}" ` +
25
+ `(expected one of: ${artifactKinds.join(", ")})`);
26
+ return kind;
27
+ }
28
+ /* translate a single ".gitignore" line into a picomatch-backed rule,
29
+ honoring the anchored-vs-floating, directory-only, and negation
30
+ semantics of ".gitignore" patterns */
31
+ static compileIgnoreRule(line) {
32
+ let pattern = line.trim();
33
+ if (pattern === "" || pattern.startsWith("#"))
34
+ return null;
35
+ let negated = false;
36
+ if (pattern.startsWith("!")) {
37
+ negated = true;
38
+ pattern = pattern.slice(1);
39
+ }
40
+ let dirOnly = false;
41
+ if (pattern.endsWith("/")) {
42
+ dirOnly = true;
43
+ pattern = pattern.slice(0, -1);
44
+ }
45
+ const anchored = pattern.includes("/");
46
+ if (pattern.startsWith("/"))
47
+ pattern = pattern.slice(1);
48
+ const glob = anchored ? pattern : `**/${pattern}`;
49
+ const isMatch = picomatch(glob, { dot: true });
50
+ return { matcher: (p) => isMatch(p), negated, dirOnly };
51
+ }
52
+ /* load the ".gitignore" rules located directly in a directory */
53
+ static loadIgnoreRules(dir) {
54
+ const file = path.join(dir, ".gitignore");
55
+ if (!fs.existsSync(file))
56
+ return [];
57
+ const rules = [];
58
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
59
+ const rule = Artifact.compileIgnoreRule(line);
60
+ if (rule !== null)
61
+ rules.push(rule);
62
+ }
63
+ return rules;
64
+ }
65
+ /* decide whether a project-relative path is ignored by the given
66
+ ordered ".gitignore" rule set (last matching rule wins) */
67
+ static isIgnored(rel, isDir, rules) {
68
+ let ignored = false;
69
+ for (const rule of rules) {
70
+ if (rule.dirOnly && !isDir)
71
+ continue;
72
+ if (rule.matcher(rel))
73
+ ignored = !rule.negated;
74
+ }
75
+ return ignored;
76
+ }
77
+ /* build the file universe by walking the project tree from the
78
+ project root, honoring ".gitignore" rules (root plus nested) and
79
+ always pruning ".git". Yields POSIX project-relative, sorted,
80
+ de-duplicated file paths */
81
+ static universe() {
82
+ const root = Task.projectRoot();
83
+ const files = new Set();
84
+ const walk = (dir, relDir, inherited) => {
85
+ const rules = [...inherited, ...Artifact.loadIgnoreRules(dir)];
86
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
87
+ if (entry.name === ".git")
88
+ continue;
89
+ const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
90
+ const isDir = entry.isDirectory();
91
+ if (Artifact.isIgnored(rel, isDir, rules))
92
+ continue;
93
+ if (isDir)
94
+ walk(path.join(dir, entry.name), rel, rules);
95
+ else if (entry.isFile())
96
+ files.add(rel);
97
+ }
98
+ };
99
+ walk(root, "", []);
100
+ return [...files].sort((a, b) => a.localeCompare(b));
101
+ }
102
+ /* raw-resolve a single kind's configuration spec against the file
103
+ universe via "seed-then-mutate" miniglob semantics: the spec is a
104
+ whitespace-separated list of tokens. */
105
+ static rawResolve(spec, all) {
106
+ const tokens = spec.split(/\s+/).filter((t) => t !== "");
107
+ if (tokens.length === 0)
108
+ return new Set();
109
+ const result = new Set(tokens[0].startsWith("!") ? all : []);
110
+ for (const token of tokens) {
111
+ const negated = token.startsWith("!");
112
+ const glob = negated ? token.slice(1) : token;
113
+ if (glob === "")
114
+ continue;
115
+ const isMatch = picomatch(glob, { dot: true });
116
+ for (const file of all) {
117
+ if (!isMatch(file))
118
+ continue;
119
+ if (negated)
120
+ result.delete(file);
121
+ else
122
+ result.add(file);
123
+ }
124
+ }
125
+ return result;
126
+ }
127
+ /* read a single scalar configuration value as a plain string */
128
+ static configString(cfg, key) {
129
+ const val = cfg.get(key);
130
+ if (val === undefined)
131
+ return "";
132
+ return String(isScalar(val) ? val.value : val);
133
+ }
134
+ /* read the configured "basedir" anchor and "files" miniglob spec
135
+ for a single kind; "basedir" is project-root-relative (POSIX,
136
+ "" ≡ project root) and "files" resolves relative to "basedir" */
137
+ static spec(log, kind) {
138
+ const cfg = new Config("config", configSchema, log);
139
+ cfg.read();
140
+ const basedir = Artifact.configString(cfg, `project.artifact.${kind}.basedir`)
141
+ .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
142
+ .replace(/^\.$/, "");
143
+ const files = Artifact.configString(cfg, `project.artifact.${kind}.files`);
144
+ return { basedir, files };
145
+ }
146
+ /* raw-resolve a single kind's "basedir"/"files" spec against the
147
+ file universe: the "files" miniglob resolves relative to
148
+ "basedir", then matches are re-prefixed with "basedir" to stay
149
+ project-relative */
150
+ static resolveKind(basedir, files, all) {
151
+ if (basedir === "")
152
+ return Artifact.rawResolve(files, all);
153
+ const prefix = `${basedir}/`;
154
+ const local = all
155
+ .filter((file) => file.startsWith(prefix))
156
+ .map((file) => file.slice(prefix.length));
157
+ const result = new Set();
158
+ for (const file of Artifact.rawResolve(files, local))
159
+ result.add(`${prefix}${file}`);
160
+ return result;
161
+ }
162
+ /* resolve the requested kinds to project-relative file lists */
163
+ static list(log, kinds) {
164
+ const all = Artifact.universe();
165
+ /* raw-resolve all five configured kinds */
166
+ const raw = new Map();
167
+ for (const kind of configuredKinds) {
168
+ const { basedir, files } = Artifact.spec(log, kind);
169
+ raw.set(kind, Artifact.resolveKind(basedir, files, all));
170
+ }
171
+ /* partition the universe by descending precedence */
172
+ const claimed = new Set();
173
+ const part = new Map();
174
+ for (const kind of configuredKinds) {
175
+ const own = [];
176
+ for (const file of raw.get(kind).size > 0 ? all : []) {
177
+ if (raw.get(kind).has(file) && !claimed.has(file)) {
178
+ own.push(file);
179
+ claimed.add(file);
180
+ }
181
+ }
182
+ part.set(kind, own);
183
+ }
184
+ part.set("othr", all.filter((file) => !claimed.has(file)));
185
+ /* project onto the requested kinds */
186
+ return kinds.map((kind) => ({
187
+ kind,
188
+ files: part.get(kind) ?? []
189
+ }));
190
+ }
191
+ /* resolve a base-relative "filename" within a kind's "basedir" to a
192
+ project-root-relative POSIX path; the implicit "othr" catch-all
193
+ has no configured "basedir" and is therefore rejected */
194
+ static name(log, kind, filename) {
195
+ if (kind === "othr")
196
+ throw new Error("artifact: kind \"othr\" has no configured basedir");
197
+ const file = filename.replace(/\\/g, "/").replace(/^\/+/, "");
198
+ if (file === "")
199
+ throw new Error("artifact: filename must not be empty");
200
+ const { basedir } = Artifact.spec(log, kind);
201
+ return basedir === "" ? file : `${basedir}/${file}`;
202
+ }
203
+ }
204
+ /* CLI command "ase artifact" */
205
+ export default class ArtifactCommand {
206
+ log;
207
+ constructor(log) {
208
+ this.log = log;
209
+ }
210
+ /* register commands */
211
+ register(program) {
212
+ /* register CLI top-level command "ase artifact" */
213
+ const artifact = program
214
+ .command("artifact")
215
+ .description("Resolve project artifact kinds to project-relative file lists")
216
+ .action(() => {
217
+ artifact.outputHelp();
218
+ process.exit(1);
219
+ });
220
+ /* register CLI sub-command "ase artifact list" */
221
+ artifact
222
+ .command("list")
223
+ .description("Resolve one or more artifact kinds to project-relative file paths")
224
+ .option("--kind <kinds>", "comma-separated list of artifact kinds " +
225
+ `(${artifactKinds.join("|")})`, artifactKinds.join(","))
226
+ .action((opts) => {
227
+ const kinds = opts.kind.split(",").map((k) => Artifact.validateKind(k.trim()));
228
+ const result = Artifact.list(this.log, kinds);
229
+ const single = result.length === 1;
230
+ for (const { kind, files } of result) {
231
+ if (!single)
232
+ process.stdout.write(`# ${kind}:\n`);
233
+ for (const file of files)
234
+ process.stdout.write(`- ${file}\n`);
235
+ }
236
+ process.exit(0);
237
+ });
238
+ /* register CLI sub-command "ase artifact name" */
239
+ artifact
240
+ .command("name")
241
+ .description("Resolve a base-relative filename within an artifact kind to a project-relative path")
242
+ .argument("<filename>", "base-relative filename within the kind's basedir")
243
+ .option("--kind <kind>", "artifact kind " +
244
+ `(${configuredKinds.join("|")})`, "code")
245
+ .action((filename, opts) => {
246
+ const kind = Artifact.validateKind(opts.kind.trim());
247
+ process.stdout.write(`${Artifact.name(this.log, kind, filename)}\n`);
248
+ process.exit(0);
249
+ });
250
+ }
251
+ }
252
+ /* MCP registration entry point for artifact tools */
253
+ export class ArtifactMCP {
254
+ log;
255
+ constructor(log) {
256
+ this.log = log;
257
+ }
258
+ /* register MCP tools */
259
+ register(mcp) {
260
+ mcp.registerTool("ase_artifact_list", {
261
+ title: "ASE artifact list",
262
+ description: "Resolve one or more artifact `kind`s to project-relative file lists. " +
263
+ "Recognized kinds are `spec`, `arch`, `code`, `docs`, `infr`, and `othr`. " +
264
+ "Returns an `artifacts` array of `{ kind, files }` objects. " +
265
+ "If `kind` is omitted or empty, all kinds are resolved.",
266
+ inputSchema: {
267
+ kind: z.array(z.string()).optional()
268
+ .describe("list of artifact kinds (`spec`, `arch`, `code`, `docs`, `infr`, " +
269
+ "`othr`); if omitted or empty, all kinds are resolved")
270
+ },
271
+ outputSchema: {
272
+ artifacts: z.array(z.object({
273
+ kind: z.string().describe("artifact kind"),
274
+ files: z.array(z.string()).describe("project-relative file paths for the kind")
275
+ })).describe("resolved artifacts, one entry per requested kind")
276
+ }
277
+ }, async (args) => {
278
+ try {
279
+ const requested = args.kind !== undefined && args.kind.length > 0 ?
280
+ args.kind : [...artifactKinds];
281
+ const kinds = requested.map((k) => Artifact.validateKind(k));
282
+ const result = { artifacts: Artifact.list(this.log, kinds) };
283
+ return {
284
+ structuredContent: result,
285
+ content: [{ type: "text", text: JSON.stringify(result) }]
286
+ };
287
+ }
288
+ catch (err) {
289
+ const message = err instanceof Error ? err.message : String(err);
290
+ return {
291
+ isError: true,
292
+ content: [{ type: "text", text: `ERROR: ${message}` }]
293
+ };
294
+ }
295
+ });
296
+ mcp.registerTool("ase_artifact_name", {
297
+ title: "ASE artifact name",
298
+ description: "Resolve a base-relative `filename` within an artifact `kind` to a project-relative path " +
299
+ "by prefixing it with the kind's configured `basedir`. " +
300
+ "Recognized kinds are `spec`, `arch`, `code`, `docs`, and `infr` " +
301
+ "(the implicit `othr` catch-all has no basedir and is rejected). " +
302
+ "If `kind` is omitted, it defaults to `code`. " +
303
+ "Returns the resolved path as `name`.",
304
+ inputSchema: {
305
+ kind: z.string().optional()
306
+ .describe("artifact kind (`spec`, `arch`, `code`, `docs`, `infr`); defaults to `code`"),
307
+ filename: z.string()
308
+ .describe("base-relative filename within the kind's basedir")
309
+ },
310
+ outputSchema: {
311
+ name: z.string().describe("project-relative file path")
312
+ }
313
+ }, async (args) => {
314
+ try {
315
+ const kind = Artifact.validateKind(args.kind ?? "code");
316
+ const result = { name: Artifact.name(this.log, kind, args.filename) };
317
+ return {
318
+ structuredContent: result,
319
+ content: [{ type: "text", text: JSON.stringify(result) }]
320
+ };
321
+ }
322
+ catch (err) {
323
+ const message = err instanceof Error ? err.message : String(err);
324
+ return {
325
+ isError: true,
326
+ content: [{ type: "text", text: `ERROR: ${message}` }]
327
+ };
328
+ }
329
+ });
330
+ }
331
+ }
package/dst/ase-config.js CHANGED
@@ -25,29 +25,41 @@ export const agentClassification = {
25
25
  /* classification presets */
26
26
  export const projectClassificationPresets = {
27
27
  vibe: {
28
+ "agent.persona": "writer",
28
29
  "project.id": "example",
29
30
  "project.name": "Example Project",
30
- "project.boxing": "black",
31
- "agent.persona": "writer"
31
+ "project.boxing": "black"
32
32
  },
33
33
  pro: {
34
+ "agent.persona": "engineer",
34
35
  "project.id": "example",
35
36
  "project.name": "Example Project",
36
- "project.boxing": "white",
37
- "agent.persona": "engineer"
37
+ "project.boxing": "white"
38
38
  },
39
39
  default: {
40
+ "agent.task": "default",
41
+ "agent.persona": "engineer",
40
42
  "project.id": "example",
41
43
  "project.name": "Example Project",
42
44
  "project.boxing": "white",
43
- "agent.persona": "engineer",
44
- "agent.task": "default"
45
+ "project.artifact.task.basedir": ".ase/task",
46
+ "project.artifact.task.files": "*.md",
47
+ "project.artifact.spec.basedir": "doc/spec",
48
+ "project.artifact.spec.files": "*.{md,txt}",
49
+ "project.artifact.arch.basedir": "doc/arch",
50
+ "project.artifact.arch.files": "*.{md,txt}",
51
+ "project.artifact.code.basedir": "src",
52
+ "project.artifact.code.files": "** !**/etc/** !**/{.gitignore,.npmignore,package.json}",
53
+ "project.artifact.docs.basedir": "doc",
54
+ "project.artifact.docs.files": "** **/{README,LICENSE,CHANGELOG}.{md,txt} !{spec,arch}/**",
55
+ "project.artifact.infr.basedir": "",
56
+ "project.artifact.infr.files": "**/{.github,.claude*,etc}/** **/{AGENTS.md,{package,tsconfig*}.json,.{git,npm}ignore}"
45
57
  },
46
58
  industry: {
59
+ "agent.persona": "engineer",
47
60
  "project.id": "example",
48
61
  "project.name": "Example Project",
49
- "project.boxing": "grey",
50
- "agent.persona": "engineer"
62
+ "project.boxing": "grey"
51
63
  }
52
64
  };
53
65
  /* hard-coded map: which scope kinds each variable may be SET on
@@ -55,7 +67,19 @@ export const projectClassificationPresets = {
55
67
  keys absent from this map default to all non-"default" scope kinds */
56
68
  export const configWritableScopes = {
57
69
  "agent.task": ["session"],
58
- "agent.skill": ["session"]
70
+ "agent.skill": ["session"],
71
+ "project.artifact.task.basedir": ["user", "project"],
72
+ "project.artifact.task.files": ["user", "project"],
73
+ "project.artifact.spec.basedir": ["user", "project"],
74
+ "project.artifact.spec.files": ["user", "project"],
75
+ "project.artifact.arch.basedir": ["user", "project"],
76
+ "project.artifact.arch.files": ["user", "project"],
77
+ "project.artifact.code.basedir": ["user", "project"],
78
+ "project.artifact.code.files": ["user", "project"],
79
+ "project.artifact.docs.basedir": ["user", "project"],
80
+ "project.artifact.docs.files": ["user", "project"],
81
+ "project.artifact.infr.basedir": ["user", "project"],
82
+ "project.artifact.infr.files": ["user", "project"]
59
83
  };
60
84
  /* default set of scope kinds writable for any unrestricted key */
61
85
  const configWritableScopesDefault = ["user", "project", "task", "session"];
@@ -130,7 +154,15 @@ export const configSchema = v.nullish(v.strictObject({
130
154
  project: v.optional(v.strictObject({
131
155
  id: v.optional(v.pipe(v.string(), v.minLength(1))),
132
156
  name: v.optional(v.pipe(v.string(), v.minLength(1))),
133
- boxing: v.optional(v.picklist(projectClassification.boxing))
157
+ boxing: v.optional(v.picklist(projectClassification.boxing)),
158
+ artifact: v.optional(v.strictObject({
159
+ spec: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
160
+ arch: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
161
+ code: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
162
+ docs: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
163
+ infr: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
164
+ task: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) }))
165
+ }))
134
166
  })),
135
167
  agent: v.optional(v.strictObject({
136
168
  persona: v.optional(v.picklist(agentClassification.persona)),
@@ -614,6 +646,20 @@ export default class ConfigCommand {
614
646
  cfg.write();
615
647
  });
616
648
  });
649
+ /* register CLI sub-command "ase config delete" */
650
+ configCmd
651
+ .command("delete")
652
+ .description("delete the value at a dotted configuration key")
653
+ .argument("<key>", "configuration key (dotted path)")
654
+ .action((key, _opts, cmd) => {
655
+ const scope = parseScope(cmd.optsWithGlobals().scope);
656
+ const cfg = new Config("config", configSchema, this.log, scope);
657
+ cfg.lock(() => {
658
+ cfg.read();
659
+ cfg.delete(key);
660
+ cfg.write();
661
+ });
662
+ });
617
663
  }
618
664
  }
619
665
  /* MCP registration entry point for layered YAML configuration access */
@@ -0,0 +1,235 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ import { z } from "zod";
7
+ /* the reusable functionality */
8
+ export class Markdown {
9
+ /* prepare Markdown for improved rendering by rewriting
10
+ unordered bullet paragraphs -- replacing the "-"/"*" bullet marker with
11
+ "◯" and splitting multi-line inline code spans into per-line single-line
12
+ spans (so each physical line carries its own closed backtick span).
13
+ Fenced code blocks (``` / ~~~) are detected line-wise and passed
14
+ through entirely verbatim, so neither the inline-span splitting nor the
15
+ bullet-marker rewriting ever touches their contents. */
16
+ static prepare(text) {
17
+ if (typeof text !== "string")
18
+ throw new Error("markdown: text must be a string");
19
+ /* segment the input line-wise into alternating non-fenced and
20
+ fenced regions: a fenced code block opens with a line whose first
21
+ non-whitespace content is a run of 3+ backticks or tildes and
22
+ closes with a matching-or-longer run of the same marker; fenced
23
+ regions are emitted verbatim while only non-fenced regions are
24
+ handed to the rewriting passes below */
25
+ const lines = text.split("\n");
26
+ let result = "";
27
+ let buf = "";
28
+ let inFence = false;
29
+ let fenceCh = "";
30
+ let fenceLen = 0;
31
+ const flush = () => {
32
+ if (buf !== "") {
33
+ result += Markdown.rewrite(buf);
34
+ buf = "";
35
+ }
36
+ };
37
+ for (let li = 0; li < lines.length; li++) {
38
+ const line = lines[li];
39
+ const nl = li < lines.length - 1 ? "\n" : "";
40
+ const m = line.match(/^(\s*)(`{3,}|~{3,})/);
41
+ if (!inFence && m) {
42
+ /* a fence-opening line: flush the pending non-fenced buffer,
43
+ enter fenced mode, and emit the opener verbatim */
44
+ flush();
45
+ inFence = true;
46
+ fenceCh = m[2][0];
47
+ fenceLen = m[2].length;
48
+ result += line + nl;
49
+ }
50
+ else if (inFence) {
51
+ /* inside a fenced block: emit verbatim and, on a matching
52
+ closing fence line, leave fenced mode */
53
+ result += line + nl;
54
+ const c = line.match(/^(\s*)(`{3,}|~{3,})\s*$/);
55
+ if (c && c[2][0] === fenceCh && c[2].length >= fenceLen)
56
+ inFence = false;
57
+ }
58
+ else
59
+ /* a regular non-fenced line: accumulate for the passes */
60
+ buf += line + nl;
61
+ }
62
+ flush();
63
+ return result;
64
+ }
65
+ /* apply the inline-span and bullet-marker rewriting passes to a chunk of
66
+ non-fenced Markdown text (see prepare() for fenced-block handling) */
67
+ static rewrite(text) {
68
+ /* PASS 1: rewrite single-backtick inline code spans that carry
69
+ backslash-escaped backticks (`\``) into CommonMark-correct spans. */
70
+ {
71
+ let pre = "";
72
+ let j = 0;
73
+ while (j < text.length) {
74
+ const ch = text[j];
75
+ if (ch !== "`") {
76
+ /* literal backslash-escaped backtick *outside* any span
77
+ is left verbatim for later scanning */
78
+ pre += ch;
79
+ j++;
80
+ continue;
81
+ }
82
+ /* scan an opening single-backtick span, capturing its raw
83
+ inner content up to the matching unescaped close backtick */
84
+ let inner = "";
85
+ let k = j + 1;
86
+ let closed = false;
87
+ let escaped = false;
88
+ while (k < text.length) {
89
+ const c = text[k];
90
+ if (c === "\\" && k + 1 < text.length && text[k + 1] === "`") {
91
+ inner += "\\`";
92
+ escaped = true;
93
+ k += 2;
94
+ continue;
95
+ }
96
+ if (c === "`") {
97
+ closed = true;
98
+ break;
99
+ }
100
+ inner += c;
101
+ k++;
102
+ }
103
+ if (!closed || !escaped) {
104
+ /* not an escaped-backtick span: emit the opening backtick
105
+ verbatim and continue scanning from just after it */
106
+ pre += "`";
107
+ j++;
108
+ continue;
109
+ }
110
+ /* un-escape inner `\`` into literal backticks, then choose a
111
+ fence longer than the longest internal backtick run */
112
+ const content = inner.replace(/\\`/g, "`");
113
+ let maxRun = 0;
114
+ let run = 0;
115
+ for (const c of content) {
116
+ if (c === "`") {
117
+ run++;
118
+ if (run > maxRun)
119
+ maxRun = run;
120
+ }
121
+ else
122
+ run = 0;
123
+ }
124
+ const fence = "`".repeat(maxRun + 1);
125
+ const pad = (content.startsWith("`") || content.endsWith("`")) ? " " : "";
126
+ pre += `${fence}${pad}${content}${pad}${fence}`;
127
+ j = k + 1;
128
+ }
129
+ text = pre;
130
+ }
131
+ /* PASS 2: split multi-line inline code spans by scanning the
132
+ entire text character-by-character while tracking whether we
133
+ are currently inside an active backtick (U+0060) span; on
134
+ every "<newline><whitespaces>" sequence encountered while
135
+ inside a span, close the span before the break and re-open
136
+ it after the indentation, so each physical line becomes its
137
+ own single-line span */
138
+ let out = "";
139
+ let fence = 0;
140
+ let i = 0;
141
+ while (i < text.length) {
142
+ const ch = text[i];
143
+ if (ch === "`") {
144
+ /* measure the full backtick run (a code-span delimiter is a
145
+ *run* of backticks; a span opened by N backticks is closed
146
+ only by a run of exactly N backticks) */
147
+ let n = 0;
148
+ while (i < text.length && text[i] === "`") {
149
+ n++;
150
+ i++;
151
+ }
152
+ if (fence === 0)
153
+ /* open a span of fence width n */
154
+ fence = n;
155
+ else if (n === fence)
156
+ /* close the active span */
157
+ fence = 0;
158
+ out += "`".repeat(n);
159
+ continue;
160
+ }
161
+ if (fence > 0 && (ch === "\r" || ch === "\n")) {
162
+ /* consume optional CR followed by mandatory LF */
163
+ let nl = "";
164
+ if (ch === "\r" && i + 1 < text.length && text[i + 1] === "\n") {
165
+ nl = "\r\n";
166
+ i += 2;
167
+ }
168
+ else if (ch === "\n") {
169
+ nl = "\n";
170
+ i++;
171
+ }
172
+ else {
173
+ /* bare CR: not a recognized newline, pass through */
174
+ out += ch;
175
+ i++;
176
+ continue;
177
+ }
178
+ /* consume the following run of SPACE/TAB whitespace */
179
+ let ws = "";
180
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) {
181
+ ws += text[i];
182
+ i++;
183
+ }
184
+ /* close span before the break, re-open after indentation,
185
+ preserving the active fence width on both sides */
186
+ const bars = "`".repeat(fence);
187
+ out += `${bars}${nl}${ws}${bars}`;
188
+ continue;
189
+ }
190
+ out += ch;
191
+ i++;
192
+ }
193
+ /* PASS 3: replace the leading "-"/"*" marker of every unordered
194
+ bullet paragraph with "◯", preserving the leading indentation
195
+ and the following whitespace; operate line-by-line so only the
196
+ actual list markers (at a line start) are affected */
197
+ out = out
198
+ .split("\n")
199
+ .map((line) => line.replace(/^(\s*)[-*]([ \t]+)/, "$1◯$2"))
200
+ .join("\n");
201
+ return out;
202
+ }
203
+ }
204
+ /* MCP registration entry point */
205
+ export class MarkdownMCP {
206
+ register(mcp) {
207
+ mcp.registerTool("ase_markdown_prepare", {
208
+ title: "ASE markdown prepare",
209
+ description: "Prepare Markdown `text` for improved rendering by rewriting " +
210
+ "unordered bullet paragraphs: replace the `-`/`*` bullet markers with `◯` " +
211
+ "and split multi-line inline code spans into per-line single-line spans. " +
212
+ "Single-backtick spans containing backslash-escaped backticks are rewritten " +
213
+ "into CommonMark-correct spans with a widened backtick fence and literal inner " +
214
+ "backticks. Returns the prepared Markdown as `text`.",
215
+ inputSchema: {
216
+ text: z.string()
217
+ .describe("Markdown text to prepare for improved rendering")
218
+ }
219
+ }, async (args) => {
220
+ try {
221
+ const text = Markdown.prepare(args.text);
222
+ return {
223
+ content: [{ type: "text", text }]
224
+ };
225
+ }
226
+ catch (err) {
227
+ const message = err instanceof Error ? err.message : String(err);
228
+ return {
229
+ isError: true,
230
+ content: [{ type: "text", text: `ERROR: ${message}` }]
231
+ };
232
+ }
233
+ });
234
+ }
235
+ }