@rse/ase 0.0.26 → 0.0.28

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/dst/ase-config.js CHANGED
@@ -325,14 +325,15 @@ export class Config {
325
325
  /* enumerate all full dotted leaf paths from the attached valibot schema */
326
326
  schemaLeafPaths() {
327
327
  const unwrap = (s) => {
328
- while (s !== undefined && s !== null && (s.type === "optional" || s.type === "nullish"
329
- || s.type === "nullable" || s.type === "undefinedable"))
330
- s = s.wrapped;
331
- return s;
328
+ let cur = s;
329
+ while (cur !== undefined && cur !== null && (cur.type === "optional" || cur.type === "nullish"
330
+ || cur.type === "nullable" || cur.type === "undefinedable"))
331
+ cur = cur.wrapped;
332
+ return cur ?? null;
332
333
  };
333
334
  const walk = (s, prefix) => {
334
335
  const u = unwrap(s);
335
- if (u !== undefined && u !== null
336
+ if (u !== null
336
337
  && (u.type === "object" || u.type === "strict_object" || u.type === "loose_object")
337
338
  && u.entries !== undefined) {
338
339
  const paths = [];
@@ -6,6 +6,7 @@
6
6
  import fs from "node:fs";
7
7
  import { InvalidArgumentError } from "commander";
8
8
  import { renderMermaidASCII } from "beautiful-mermaid";
9
+ import { z } from "zod";
9
10
  /* custom argument parser for Commander: non-negative integer */
10
11
  const parseInteger = (name) => (value) => {
11
12
  const n = Number.parseInt(value, 10);
@@ -19,57 +20,6 @@ const parseColorMode = (name) => (value) => {
19
20
  throw new InvalidArgumentError(`${name} must be "none", "ansi16", or "ansi256"`);
20
21
  return value;
21
22
  };
22
- /* detect terminal column width */
23
- export const detectTermWidth = () => {
24
- let width = 0;
25
- /* attempt 1: query environment variable */
26
- if (process.env.ASE_TERM_WIDTH !== undefined) {
27
- const cols = Number.parseInt(process.env.ASE_TERM_WIDTH, 10);
28
- if (Number.isFinite(cols) && cols > 0)
29
- width = cols;
30
- }
31
- /* attempt 2: query stdout */
32
- if (width === 0 && process.stdout.isTTY) {
33
- const cols = process.stdout.columns;
34
- if (typeof cols === "number" && cols > 0)
35
- width = cols;
36
- }
37
- return width;
38
- };
39
- /* detect terminal row height */
40
- export const detectTermHeight = () => {
41
- let height = 0;
42
- /* attempt 1: query environment variable */
43
- if (process.env.ASE_TERM_HEIGHT !== undefined) {
44
- const rows = Number.parseInt(process.env.ASE_TERM_HEIGHT, 10);
45
- if (Number.isFinite(rows) && rows > 0)
46
- height = rows;
47
- }
48
- /* attempt 2: query stdout */
49
- if (height === 0 && process.stdout.isTTY) {
50
- const rows = process.stdout.rows;
51
- if (typeof rows === "number" && rows > 0)
52
- height = rows;
53
- }
54
- return height;
55
- };
56
- /* detect terminal color capability */
57
- const detectColorMode = () => {
58
- let mode = "none";
59
- /* attempt 1: query environment variable (explicitly) */
60
- if (process.env.ASE_TERM_COLORS !== undefined)
61
- if (process.env.ASE_TERM_COLORS.match(/^(?:none|ansi16|ansi256)$/) !== null)
62
- mode = process.env.ASE_TERM_COLORS;
63
- /* attempt 2: query stdout */
64
- if (mode === "none" && process.stdout.isTTY) {
65
- const depth = process.stdout.getColorDepth();
66
- if (depth >= 8)
67
- mode = "ansi256";
68
- else if (depth >= 4)
69
- mode = "ansi16";
70
- }
71
- return mode;
72
- };
73
23
  /* truncate a single rendered line to a maximum visible column,
74
24
  preserving ANSI escape sequences (CSI ...m) and appending an ANSI
75
25
  reset sequence if any styling was active at the truncation point */
@@ -135,70 +85,124 @@ const visibleWidth = (line) => {
135
85
  }
136
86
  return visible;
137
87
  };
138
- /* pure rendering helper: turn a Mermaid source string plus options into
139
- a rendered Unicode/ASCII diagram string. Throws on render failure. */
140
- export const renderDiagram = (src, opts) => {
141
- /* create diagram rendering */
142
- let out = renderMermaidASCII(src, {
143
- useAscii: opts.ascii,
144
- paddingX: opts.nodeMarginX,
145
- paddingY: opts.nodeMarginY,
146
- boxBorderPadding: opts.nodePadding,
147
- colorMode: opts.colorMode,
148
- theme: opts.colorMode !== "none" ? {
149
- fg: "#000000",
150
- border: "#a0a0a0",
151
- junction: "#a0a0a0",
152
- arrow: "#404040",
153
- line: "#707070",
154
- corner: "#707070"
155
- } : {
156
- fg: "#000000",
157
- border: "#000000",
158
- junction: "#000000",
159
- arrow: "#000000",
160
- line: "#000000",
161
- corner: "#000000"
88
+ /* reusable functionality: Mermaid diagram rendering as Unicode/ASCII art */
89
+ export class Diagram {
90
+ /* detect terminal column width */
91
+ static detectTermWidth() {
92
+ let width = 0;
93
+ /* attempt 1: query environment variable */
94
+ if (process.env.ASE_TERM_WIDTH !== undefined) {
95
+ const cols = Number.parseInt(process.env.ASE_TERM_WIDTH, 10);
96
+ if (Number.isFinite(cols) && cols > 0)
97
+ width = cols;
162
98
  }
163
- });
164
- /* optionally clip diagram rendering */
165
- const termWidth = opts.terminalWidth;
166
- const termHeight = opts.terminalHeight;
167
- if (termWidth > 0 || termHeight > 0) {
168
- const maxWidth = termWidth > 0 ? termWidth - opts.diagramClipX : 0;
169
- const maxHeight = termHeight > 0 ? termHeight - opts.diagramClipY : 0;
170
- const trailingNL = out.endsWith("\n");
171
- let lines = (trailingNL ? out.slice(0, -1) : out).split("\n");
172
- let widthWarn = "";
173
- let heightWarn = "";
174
- if (maxWidth > 0) {
175
- const widest = lines.reduce((m, l) => Math.max(m, visibleWidth(l)), 0);
176
- if (widest > maxWidth)
177
- widthWarn =
178
- `ase diagram: WARNING: rendered diagram width ${widest} exceeds budget ${maxWidth}; ` +
179
- "rightmost content was clipped. Please regenerate the Mermaid source to fit " +
180
- `within ${maxWidth} chars by preferring a portrait orientation ` +
181
- "(\"flowchart TB\", top-to-bottom) over landscape (\"LR\"/\"RL\"/\"BT\"), " +
182
- "reducing siblings per row, abbreviating node labels, or restructuring " +
183
- "into nested subgraph hierarchies.";
184
- lines = lines.map((l) => truncateAnsiLine(l, maxWidth));
99
+ /* attempt 2: query stdout */
100
+ if (width === 0 && process.stdout.isTTY) {
101
+ const cols = process.stdout.columns;
102
+ if (typeof cols === "number" && cols > 0)
103
+ width = cols;
185
104
  }
186
- if (maxHeight > 0 && lines.length > maxHeight) {
187
- const overflow = lines.length - maxHeight;
188
- heightWarn =
189
- `ase diagram: WARNING: rendered diagram height ${lines.length} exceeds budget ${maxHeight}; ` +
190
- `bottom ${overflow} line(s) were clipped. Please regenerate the Mermaid source to fit ` +
191
- `within ${maxHeight} lines by reducing depth or splitting into multiple diagrams.`;
192
- lines = lines.slice(0, maxHeight);
105
+ return width;
106
+ }
107
+ /* detect terminal row height */
108
+ static detectTermHeight() {
109
+ let height = 0;
110
+ /* attempt 1: query environment variable */
111
+ if (process.env.ASE_TERM_HEIGHT !== undefined) {
112
+ const rows = Number.parseInt(process.env.ASE_TERM_HEIGHT, 10);
113
+ if (Number.isFinite(rows) && rows > 0)
114
+ height = rows;
115
+ }
116
+ /* attempt 2: query stdout */
117
+ if (height === 0 && process.stdout.isTTY) {
118
+ const rows = process.stdout.rows;
119
+ if (typeof rows === "number" && rows > 0)
120
+ height = rows;
193
121
  }
194
- out = lines.join("\n") + (trailingNL ? "\n" : "");
195
- if (widthWarn !== "")
196
- out += "\n" + widthWarn + "\n";
197
- if (heightWarn !== "")
198
- out += "\n" + heightWarn + "\n";
122
+ return height;
199
123
  }
200
- return out;
201
- };
124
+ /* detect terminal color capability */
125
+ static detectColorMode() {
126
+ let mode = "none";
127
+ /* attempt 1: query environment variable (explicitly) */
128
+ if (process.env.ASE_TERM_COLORS !== undefined)
129
+ if (process.env.ASE_TERM_COLORS.match(/^(?:none|ansi16|ansi256)$/) !== null)
130
+ mode = process.env.ASE_TERM_COLORS;
131
+ /* attempt 2: query stdout */
132
+ if (mode === "none" && process.stdout.isTTY) {
133
+ const depth = process.stdout.getColorDepth();
134
+ if (depth >= 8)
135
+ mode = "ansi256";
136
+ else if (depth >= 4)
137
+ mode = "ansi16";
138
+ }
139
+ return mode;
140
+ }
141
+ /* pure rendering helper: turn a Mermaid source string plus options into
142
+ a rendered Unicode/ASCII diagram string. Throws on render failure. */
143
+ static render(src, opts) {
144
+ /* create diagram rendering */
145
+ let out = renderMermaidASCII(src, {
146
+ useAscii: opts.ascii,
147
+ paddingX: opts.nodeMarginX,
148
+ paddingY: opts.nodeMarginY,
149
+ boxBorderPadding: opts.nodePadding,
150
+ colorMode: opts.colorMode,
151
+ theme: opts.colorMode !== "none" ? {
152
+ fg: "#000000",
153
+ border: "#a0a0a0",
154
+ junction: "#a0a0a0",
155
+ arrow: "#404040",
156
+ line: "#707070",
157
+ corner: "#707070"
158
+ } : {
159
+ fg: "#000000",
160
+ border: "#000000",
161
+ junction: "#000000",
162
+ arrow: "#000000",
163
+ line: "#000000",
164
+ corner: "#000000"
165
+ }
166
+ });
167
+ /* optionally clip diagram rendering */
168
+ const termWidth = opts.terminalWidth;
169
+ const termHeight = opts.terminalHeight;
170
+ if (termWidth > 0 || termHeight > 0) {
171
+ const maxWidth = termWidth > 0 ? termWidth - opts.diagramClipX : 0;
172
+ const maxHeight = termHeight > 0 ? termHeight - opts.diagramClipY : 0;
173
+ const trailingNL = out.endsWith("\n");
174
+ let lines = (trailingNL ? out.slice(0, -1) : out).split("\n");
175
+ let widthWarn = "";
176
+ let heightWarn = "";
177
+ if (maxWidth > 0) {
178
+ const widest = lines.reduce((m, l) => Math.max(m, visibleWidth(l)), 0);
179
+ if (widest > maxWidth)
180
+ widthWarn =
181
+ `ase diagram: WARNING: rendered diagram width ${widest} exceeds budget ${maxWidth}; ` +
182
+ "rightmost content was clipped. Please regenerate the Mermaid source to fit " +
183
+ `within ${maxWidth} chars by preferring a portrait orientation ` +
184
+ "(\"flowchart TB\", top-to-bottom) over landscape (\"LR\"/\"RL\"/\"BT\"), " +
185
+ "reducing siblings per row, abbreviating node labels, or restructuring " +
186
+ "into nested subgraph hierarchies.";
187
+ lines = lines.map((l) => truncateAnsiLine(l, maxWidth));
188
+ }
189
+ if (maxHeight > 0 && lines.length > maxHeight) {
190
+ const overflow = lines.length - maxHeight;
191
+ heightWarn =
192
+ `ase diagram: WARNING: rendered diagram height ${lines.length} exceeds budget ${maxHeight}; ` +
193
+ `bottom ${overflow} line(s) were clipped. Please regenerate the Mermaid source to fit ` +
194
+ `within ${maxHeight} lines by reducing depth or splitting into multiple diagrams.`;
195
+ lines = lines.slice(0, maxHeight);
196
+ }
197
+ out = lines.join("\n") + (trailingNL ? "\n" : "");
198
+ if (widthWarn !== "")
199
+ out += "\n" + widthWarn + "\n";
200
+ if (heightWarn !== "")
201
+ out += "\n" + heightWarn + "\n";
202
+ }
203
+ return out;
204
+ }
205
+ }
202
206
  /* read stdin into a single string */
203
207
  const readStdin = async () => {
204
208
  const chunks = [];
@@ -219,14 +223,14 @@ export default class DiagramCommand {
219
223
  .description("Render Mermaid diagram specification as Unicode/ASCII art")
220
224
  .option("-i, --input <file>", "read Mermaid source from file instead of stdin")
221
225
  .option("-a, --ascii", "emit plain ASCII (+-|) instead of Unicode box-drawing", false)
222
- .option("-c, --color-mode <mode>", "force color mode (\"none\", \"ansi16\", or \"ansi256\")", parseColorMode("--color-mode"), detectColorMode())
226
+ .option("-c, --color-mode <mode>", "force color mode (\"none\", \"ansi16\", or \"ansi256\")", parseColorMode("--color-mode"), Diagram.detectColorMode())
223
227
  .option("--node-margin-x <n>", "horizontal margin between nodes of <n> characters", parseInteger("--node-margin-x"), 3)
224
228
  .option("--node-margin-y <n>", "vertical margin between nodes of <n> lines", parseInteger("--node-margin-y"), 3)
225
229
  .option("--node-padding <n>", "horizontal and vertical inner node padding with <n> characters", parseInteger("--node-padding"), 1)
226
230
  .option("--diagram-clip-x <n>", "extra horizontal clipping of diagram to terminal width minus <n> characters", parseInteger("--diagram-clip-x"), 0)
227
231
  .option("--diagram-clip-y <n>", "extra vertical clipping of diagram to terminal height minus <n> lines", parseInteger("--diagram-clip-y"), 0)
228
- .option("--terminal-width <n>", "width of terminal of <n> characters (for diagram clipping)", parseInteger("--terminal-width"), detectTermWidth())
229
- .option("--terminal-height <n>", "height of terminal of <n> lines (for diagram clipping)", parseInteger("--terminal-height"), detectTermHeight())
232
+ .option("--terminal-width <n>", "width of terminal of <n> characters (for diagram clipping)", parseInteger("--terminal-width"), Diagram.detectTermWidth())
233
+ .option("--terminal-height <n>", "height of terminal of <n> lines (for diagram clipping)", parseInteger("--terminal-height"), Diagram.detectTermHeight())
230
234
  .action(async (opts) => {
231
235
  /* fetch Mermaid diagram specification from stdin */
232
236
  let src;
@@ -241,7 +245,7 @@ export default class DiagramCommand {
241
245
  /* create diagram rendering */
242
246
  let out;
243
247
  try {
244
- out = renderDiagram(src, {
248
+ out = Diagram.render(src, {
245
249
  ascii: opts.ascii ?? false,
246
250
  colorMode: opts.colorMode,
247
251
  nodeMarginX: opts.nodeMarginX,
@@ -265,3 +269,68 @@ export default class DiagramCommand {
265
269
  });
266
270
  }
267
271
  }
272
+ /* MCP registration entry point for diagram tools */
273
+ export class DiagramMCP {
274
+ register(mcp) {
275
+ mcp.registerTool("diagram", {
276
+ title: "ASE diagram render",
277
+ description: "Render a Mermaid diagram as Unicode/ASCII art. " +
278
+ "Use for visualizing " +
279
+ "structure/layout/components/dependencies as a Flowchart, " +
280
+ "control-flow/branching/concurrency as a Flowchart, " +
281
+ "state-machine/states/transitions as an UML State Diagram, " +
282
+ "data-flow/actors/messages/protocols as an UML Sequence Diagram, " +
283
+ "data-structure/classes/methods as an UML Class Diagram, " +
284
+ "data-model/entities/relationships as an ER Diagram, or " +
285
+ "metrics/distributions/time-series as an XY-Chart. " +
286
+ "Pass the Mermaid diagram specification as `diagram`. " +
287
+ "Returns the rendered art as `text`.",
288
+ inputSchema: {
289
+ diagram: z.string()
290
+ .describe("Mermaid diagram specification"),
291
+ ascii: z.boolean().default(false)
292
+ .describe("emit plain ASCII (+-|) instead of Unicode box-drawing characters"),
293
+ colorMode: z.enum(["none", "ansi16", "ansi256"]).default("none")
294
+ .describe("color mode for ANSI escape sequences in the rendered output"),
295
+ nodeMarginX: z.number().int().min(0).default(3)
296
+ .describe("horizontal margin between nodes, in characters"),
297
+ nodeMarginY: z.number().int().min(0).default(3)
298
+ .describe("vertical margin between nodes, in lines"),
299
+ nodePadding: z.number().int().min(0).default(1)
300
+ .describe("inner horizontal and vertical padding within each node, in characters"),
301
+ diagramClipX: z.number().int().min(0).default(0)
302
+ .describe("extra horizontal clipping: subtract this many characters from `terminalWidth`"),
303
+ diagramClipY: z.number().int().min(0).default(0)
304
+ .describe("extra vertical clipping: subtract this many lines from `terminalHeight`"),
305
+ terminalWidth: z.number().int().min(0).default(Diagram.detectTermWidth())
306
+ .describe("terminal width in characters; 0 disables horizontal clipping; defaults to ASE_TERM_WIDTH env var if set"),
307
+ terminalHeight: z.number().int().min(0).default(Diagram.detectTermHeight())
308
+ .describe("terminal height in lines; 0 disables vertical clipping; defaults to ASE_TERM_HEIGHT env var if set")
309
+ }
310
+ }, async (args) => {
311
+ try {
312
+ const out = Diagram.render(args.diagram, {
313
+ ascii: args.ascii,
314
+ colorMode: args.colorMode,
315
+ nodeMarginX: args.nodeMarginX,
316
+ nodeMarginY: args.nodeMarginY,
317
+ nodePadding: args.nodePadding,
318
+ diagramClipX: args.diagramClipX,
319
+ diagramClipY: args.diagramClipY,
320
+ terminalWidth: args.terminalWidth,
321
+ terminalHeight: args.terminalHeight
322
+ });
323
+ return {
324
+ content: [{ type: "text", text: out }]
325
+ };
326
+ }
327
+ catch (err) {
328
+ const message = err instanceof Error ? err.message : String(err);
329
+ return {
330
+ isError: true,
331
+ content: [{ type: "text", text: `diagram: render failed: ${message}` }]
332
+ };
333
+ }
334
+ });
335
+ }
336
+ }
package/dst/ase-hook.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
+ import os from "node:os";
8
9
  import { execaSync } from "execa";
9
10
  import Version from "./ase-version.js";
10
11
  import { Config, configSchema, parseScope } from "./ase-config.js";
@@ -79,7 +80,7 @@ export default class HookCommand {
79
80
  const versionHints = [];
80
81
  if (versionCurrentPlugin !== versionCurrentTool)
81
82
  versionHints.push("**WARNING:** version *mismatch*: " +
82
- `tool: **${versionCurrentPlugin}**, plugin: **${versionCurrentTool}**`);
83
+ `tool: **${versionCurrentTool}**, plugin: **${versionCurrentPlugin}**`);
83
84
  if (versionCurrentTool !== versionLatestTool)
84
85
  versionHints.push(`**NOTICE:** *latest* version: **${versionLatestTool}**, please update!`);
85
86
  if (process.env.ASE_SETUP_DEV !== undefined)
@@ -91,22 +92,25 @@ export default class HookCommand {
91
92
  const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
92
93
  /* determine session id */
93
94
  const sessionId = input.session_id ?? input.sessionId ?? "";
94
- /* establish config context */
95
- const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
95
+ /* establish config context (session-scoped only if a valid sessionId is present) */
96
+ const hasSession = /^[A-Za-z0-9._-]+$/.test(sessionId);
97
+ const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
96
98
  try {
97
99
  cfg.read();
98
100
  }
99
101
  catch (_e) {
100
102
  /* best-effort: ignore failures */
101
103
  }
102
- /* determine task id */
104
+ /* determine task id (only persist when scoped to a real session) */
103
105
  const taskId = process.env.ASE_TASK_ID ?? "default";
104
- try {
105
- cfg.set("agent.task", taskId);
106
- cfg.write();
107
- }
108
- catch (_e) {
109
- /* best-effort: ignore failures */
106
+ if (hasSession) {
107
+ try {
108
+ cfg.set("agent.task", taskId);
109
+ cfg.write();
110
+ }
111
+ catch (_e) {
112
+ /* best-effort: ignore failures */
113
+ }
110
114
  }
111
115
  /* determine project id */
112
116
  const cwd = input.cwd ?? process.cwd();
@@ -170,6 +174,26 @@ export default class HookCommand {
170
174
  process.stdout.write(JSON.stringify(payload));
171
175
  return 0;
172
176
  }
177
+ /* handler for "ase hook session-end" (both tools) */
178
+ doSessionEnd(_tool) {
179
+ /* read session information (Claude Code uses snake_case fields,
180
+ Copilot CLI uses camelCase fields) */
181
+ const stdin = fs.readFileSync(0, "utf8");
182
+ const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
183
+ /* determine session id */
184
+ const sessionId = input.session_id ?? input.sessionId ?? "";
185
+ /* remove the session directory ~/.ase/session/<id> (only for a valid sessionId) */
186
+ if (/^[A-Za-z0-9._-]+$/.test(sessionId)) {
187
+ const dir = path.join(os.homedir(), ".ase", "session", sessionId);
188
+ try {
189
+ fs.rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ catch (_e) {
192
+ /* best-effort: ignore failures */
193
+ }
194
+ }
195
+ return 0;
196
+ }
173
197
  /* handler for "ase hook pre-tool-use" (both tools) */
174
198
  doPreToolUse(tool) {
175
199
  const spec = toolSpecs[tool];
@@ -251,6 +275,14 @@ export default class HookCommand {
251
275
  .action(async (opts) => {
252
276
  process.exit(await this.doSessionStart(this.parseTool(opts.tool)));
253
277
  });
278
+ /* register CLI sub-command "ase hook session-end" */
279
+ hookCmd
280
+ .command("session-end")
281
+ .description("handle SessionEnd hook event")
282
+ .option("-t, --tool <tool>", "target tool (\"claude\" or \"copilot\")", toolDflt)
283
+ .action((opts) => {
284
+ process.exit(this.doSessionEnd(this.parseTool(opts.tool)));
285
+ });
254
286
  /* register CLI sub-command "ase hook pre-tool-use" */
255
287
  hookCmd
256
288
  .command("pre-tool-use")
package/dst/ase-mcp.js CHANGED
@@ -5,42 +5,11 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import axios from "axios";
9
- import * as v from "valibot";
10
8
  import { execa } from "execa";
11
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
10
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
11
  import { Config, configSchema } from "./ase-config.js";
14
- const HOST = "127.0.0.1";
15
- /* schema for ".ase/service.yaml" (same shape as in ase-service.ts) */
16
- const serviceSchema = v.nullish(v.strictObject({
17
- port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1024), v.maxValue(65535)))
18
- }));
19
- /* distinguish ECONNREFUSED from other Axios transport errors */
20
- const isConnRefused = (err) => {
21
- const e = err;
22
- return e?.code === "ECONNREFUSED" || e?.cause?.code === "ECONNREFUSED";
23
- };
24
- /* probe the service and verify ASE identity banner */
25
- const probe = async (port, projectId) => {
26
- try {
27
- const r = await axios.request({
28
- method: "OPTIONS",
29
- url: `http://${HOST}:${port}/`,
30
- timeout: 2000,
31
- validateStatus: () => true
32
- });
33
- if (r.status < 200 || r.status >= 300)
34
- return false;
35
- const d = r.data;
36
- return d?.ase === true && d?.projectId === projectId;
37
- }
38
- catch (err) {
39
- if (isConnRefused(err))
40
- return null;
41
- throw err;
42
- }
43
- };
12
+ import { SERVICE_HOST as HOST, serviceSchema, probe } from "./ase-service.js";
44
13
  /* CLI command "ase mcp" */
45
14
  export default class MCPCommand {
46
15
  log;
@@ -0,0 +1,87 @@
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 { isScalar } from "yaml";
7
+ import { z } from "zod";
8
+ import { Config, configSchema, parseScope } from "./ase-config.js";
9
+ /* reusable functionality: ASE agent persona style get/set */
10
+ export class Persona {
11
+ /* allowed persona style values */
12
+ static styles = ["writer", "engineer", "telegrapher", "caveman"];
13
+ /* get the effective persona style for an optional session;
14
+ returns the default "engineer" if nothing is configured */
15
+ static get(log, session) {
16
+ const scope = session !== undefined ?
17
+ parseScope(`session:${session}`) :
18
+ parseScope(undefined);
19
+ const cfg = new Config("config", configSchema, log, scope);
20
+ cfg.read();
21
+ const val = cfg.get("agent.persona");
22
+ if (val === undefined)
23
+ return "engineer";
24
+ return String(isScalar(val) ? val.value : val);
25
+ }
26
+ /* set the persona style on the strongest scope of an optional session */
27
+ static set(log, style, session) {
28
+ const scope = session !== undefined ?
29
+ parseScope(`session:${session}`) :
30
+ parseScope(undefined);
31
+ const cfg = new Config("config", configSchema, log, scope);
32
+ cfg.read();
33
+ cfg.set("agent.persona", style);
34
+ cfg.write();
35
+ }
36
+ }
37
+ /* MCP registration entry point for persona tools */
38
+ export default class PersonaMCP {
39
+ log;
40
+ constructor(log) {
41
+ this.log = log;
42
+ }
43
+ register(mcp) {
44
+ mcp.registerTool("persona", {
45
+ title: "ASE persona style get/set",
46
+ description: "Get or set the active ASE agent persona `style`. " +
47
+ "If `style` is provided, it sets the persona style, " +
48
+ "otherwise it returns the current persona `style`. " +
49
+ "If `session` is provided, the operation is scoped to that session, " +
50
+ "otherwise it operates on the broadest scope (user/project cascade). " +
51
+ "Allowed styles: \"writer\" (decorative, eloquent, explaining), " +
52
+ "\"engineer\" (brief, factual, accurate), " +
53
+ "\"telegrapher\" (very brief, factual, abbreviating), " +
54
+ "\"caveman\" (ultra brief, rough, stuttering).",
55
+ inputSchema: {
56
+ style: z.enum(Persona.styles).optional()
57
+ .describe("persona style to set; if omitted, the current persona style is returned"),
58
+ session: z.string().optional()
59
+ .describe("session identifier (allowed characters: A-Z, a-z, 0-9, '-'); " +
60
+ "if omitted, the operation is not scoped to a specific session")
61
+ }
62
+ }, async (args) => {
63
+ try {
64
+ if (args.style !== undefined) {
65
+ Persona.set(this.log, args.style, args.session);
66
+ const where = args.session !== undefined ?
67
+ ` for session "${args.session}"` : "";
68
+ const msg = `persona: OK: set agent.persona to "${args.style}"${where}`;
69
+ return {
70
+ content: [{ type: "text", text: msg }]
71
+ };
72
+ }
73
+ const text = Persona.get(this.log, args.session);
74
+ return {
75
+ content: [{ type: "text", text }]
76
+ };
77
+ }
78
+ catch (err) {
79
+ const message = err instanceof Error ? err.message : String(err);
80
+ return {
81
+ isError: true,
82
+ content: [{ type: "text", text: `persona: ERROR: ${message}` }]
83
+ };
84
+ }
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,38 @@
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 axios from "axios";
7
+ import * as v from "valibot";
8
+ /* shared service host */
9
+ export const SERVICE_HOST = "127.0.0.1";
10
+ /* schema for ".ase/service.yaml" */
11
+ export const serviceSchema = v.nullish(v.strictObject({
12
+ port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1024), v.maxValue(65535)))
13
+ }));
14
+ /* distinguish ECONNREFUSED from other Axios transport errors */
15
+ export const isConnRefused = (err) => {
16
+ const e = err;
17
+ return e?.code === "ECONNREFUSED" || e?.cause?.code === "ECONNREFUSED";
18
+ };
19
+ /* probe the service and verify ASE identity banner */
20
+ export const probe = async (port, projectId) => {
21
+ try {
22
+ const r = await axios.request({
23
+ method: "OPTIONS",
24
+ url: `http://${SERVICE_HOST}:${port}/`,
25
+ timeout: 2000,
26
+ validateStatus: () => true
27
+ });
28
+ if (r.status < 200 || r.status >= 300)
29
+ return false;
30
+ const d = r.data;
31
+ return d?.ase === true && d?.projectId === projectId;
32
+ }
33
+ catch (err) {
34
+ if (isConnRefused(err))
35
+ return null;
36
+ throw err;
37
+ }
38
+ };