@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 +6 -5
- package/dst/ase-diagram.js +184 -115
- package/dst/ase-hook.js +42 -10
- package/dst/ase-mcp.js +1 -32
- package/dst/ase-persona.js +87 -0
- package/dst/ase-service-probe.js +38 -0
- package/dst/ase-service.js +168 -401
- package/dst/ase-setup.js +6 -5
- package/dst/ase-statusline.js +13 -3
- package/dst/ase-task.js +314 -95
- package/dst/ase.js +3 -3
- package/package.json +3 -3
package/dst/ase-service.js
CHANGED
|
@@ -10,79 +10,36 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import Hapi from "@hapi/hapi";
|
|
12
12
|
import axios from "axios";
|
|
13
|
-
import { isMap
|
|
14
|
-
import * as v from "valibot";
|
|
13
|
+
import { isMap } from "yaml";
|
|
15
14
|
import prettyMs from "pretty-ms";
|
|
15
|
+
import * as v from "valibot";
|
|
16
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
17
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { DateTime } from "luxon";
|
|
20
|
-
import { Config, configSchema
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
20
|
+
import { Config, configSchema } from "./ase-config.js";
|
|
21
|
+
import { DiagramMCP } from "./ase-diagram.js";
|
|
22
|
+
import { TaskMCP } from "./ase-task.js";
|
|
23
|
+
import PersonaMCP from "./ase-persona.js";
|
|
23
24
|
import pkg from "../package.json" with { type: "json" };
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const HOST =
|
|
27
|
-
const IDLE_MS = 30 * 60 * 1000;
|
|
28
|
-
const TICK_MS = 60 * 1000;
|
|
29
|
-
const PORT_MIN = 42000;
|
|
30
|
-
const PORT_MAX = 44000;
|
|
31
|
-
const PORT_TRIES = 20;
|
|
25
|
+
/* shared service host */
|
|
26
|
+
export const SERVICE_HOST = "127.0.0.1";
|
|
27
|
+
const HOST = SERVICE_HOST;
|
|
32
28
|
/* schema for ".ase/service.yaml" */
|
|
33
|
-
const serviceSchema = v.nullish(v.strictObject({
|
|
29
|
+
export const serviceSchema = v.nullish(v.strictObject({
|
|
34
30
|
port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1024), v.maxValue(65535)))
|
|
35
31
|
}));
|
|
36
|
-
/* try binding a single candidate port to verify availability */
|
|
37
|
-
const tryBind = (port) => {
|
|
38
|
-
return new Promise((resolve) => {
|
|
39
|
-
const s = net.createServer();
|
|
40
|
-
s.once("error", () => {
|
|
41
|
-
resolve(false);
|
|
42
|
-
});
|
|
43
|
-
s.once("listening", () => {
|
|
44
|
-
s.close(() => resolve(true));
|
|
45
|
-
});
|
|
46
|
-
s.listen(port, HOST);
|
|
47
|
-
});
|
|
48
|
-
};
|
|
49
|
-
/* allocate a fresh random port in PORT_MIN..PORT_MAX */
|
|
50
|
-
const allocatePort = async () => {
|
|
51
|
-
for (let i = 0; i < PORT_TRIES; i++) {
|
|
52
|
-
const p = PORT_MIN + Math.floor(Math.random() * (PORT_MAX - PORT_MIN + 1));
|
|
53
|
-
if (await tryBind(p))
|
|
54
|
-
return p;
|
|
55
|
-
}
|
|
56
|
-
throw new Error(`failed to allocate a port in ${PORT_MIN}..${PORT_MAX} after ${PORT_TRIES} attempts`);
|
|
57
|
-
};
|
|
58
|
-
/* persist an allocated port into ".ase/service.yaml" */
|
|
59
|
-
const persistPort = (svc, port) => {
|
|
60
|
-
svc.set("port", port);
|
|
61
|
-
svc.write();
|
|
62
|
-
};
|
|
63
|
-
/* clear the persisted port and remove ".ase/service.yaml" if it is empty */
|
|
64
|
-
const clearPort = (svc) => {
|
|
65
|
-
svc.delete("port");
|
|
66
|
-
const root = svc.get();
|
|
67
|
-
const empty = root === undefined || root === null || (isMap(root) && root.items.length === 0);
|
|
68
|
-
if (empty) {
|
|
69
|
-
if (fs.existsSync(svc.filename))
|
|
70
|
-
fs.rmSync(svc.filename);
|
|
71
|
-
}
|
|
72
|
-
else
|
|
73
|
-
svc.write();
|
|
74
|
-
};
|
|
75
32
|
/* distinguish ECONNREFUSED from other Axios transport errors */
|
|
76
|
-
const isConnRefused = (err) => {
|
|
33
|
+
export const isConnRefused = (err) => {
|
|
77
34
|
const e = err;
|
|
78
35
|
return e?.code === "ECONNREFUSED" || e?.cause?.code === "ECONNREFUSED";
|
|
79
36
|
};
|
|
80
37
|
/* probe the service and verify ASE identity banner */
|
|
81
|
-
const probe = async (port, projectId) => {
|
|
38
|
+
export const probe = async (port, projectId) => {
|
|
82
39
|
try {
|
|
83
40
|
const r = await axios.request({
|
|
84
41
|
method: "OPTIONS",
|
|
85
|
-
url: `http://${
|
|
42
|
+
url: `http://${SERVICE_HOST}:${port}/`,
|
|
86
43
|
timeout: 2000,
|
|
87
44
|
validateStatus: () => true
|
|
88
45
|
});
|
|
@@ -97,52 +54,149 @@ const probe = async (port, projectId) => {
|
|
|
97
54
|
throw err;
|
|
98
55
|
}
|
|
99
56
|
};
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
let
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
fs.readSync(fd, buf, 0, n, pos);
|
|
129
|
-
tail = buf.toString("utf8", 0, n) + tail;
|
|
130
|
-
count = 0;
|
|
131
|
-
for (let i = 0; i < tail.length; i++)
|
|
132
|
-
if (tail.charCodeAt(i) === 10)
|
|
133
|
-
count++;
|
|
57
|
+
const SERVE_ENV = "ASE_SERVICE_SERVE";
|
|
58
|
+
const PORT_ENV = "ASE_SERVICE_PORT";
|
|
59
|
+
const IDLE_MS = 30 * 60 * 1000;
|
|
60
|
+
const TICK_MS = 60 * 1000;
|
|
61
|
+
const PORT_MIN = 42000;
|
|
62
|
+
const PORT_MAX = 44000;
|
|
63
|
+
const PORT_TRIES = 20;
|
|
64
|
+
/* reusable functionality: port allocation, persistence, and process spawning */
|
|
65
|
+
export class Service {
|
|
66
|
+
/* try binding a single candidate port to verify availability */
|
|
67
|
+
static tryBind(port) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const s = net.createServer();
|
|
70
|
+
s.once("error", () => {
|
|
71
|
+
resolve(false);
|
|
72
|
+
});
|
|
73
|
+
s.once("listening", () => {
|
|
74
|
+
s.close(() => resolve(true));
|
|
75
|
+
});
|
|
76
|
+
s.listen(port, HOST);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/* allocate a fresh random port in PORT_MIN..PORT_MAX */
|
|
80
|
+
static async allocatePort() {
|
|
81
|
+
for (let i = 0; i < PORT_TRIES; i++) {
|
|
82
|
+
const p = PORT_MIN + Math.floor(Math.random() * (PORT_MAX - PORT_MIN + 1));
|
|
83
|
+
if (await Service.tryBind(p))
|
|
84
|
+
return p;
|
|
134
85
|
}
|
|
135
|
-
|
|
136
|
-
return all.slice(-lines).join("\n");
|
|
86
|
+
throw new Error(`failed to allocate a port in ${PORT_MIN}..${PORT_MAX} after ${PORT_TRIES} attempts`);
|
|
137
87
|
}
|
|
138
|
-
|
|
139
|
-
|
|
88
|
+
/* persist an allocated port into ".ase/service.yaml" */
|
|
89
|
+
static persistPort(svc, port) {
|
|
90
|
+
svc.set("port", port);
|
|
91
|
+
svc.write();
|
|
140
92
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
93
|
+
/* clear the persisted port and remove ".ase/service.yaml" if it is empty */
|
|
94
|
+
static clearPort(svc) {
|
|
95
|
+
svc.delete("port");
|
|
96
|
+
const root = svc.get();
|
|
97
|
+
const empty = root === undefined || root === null || (isMap(root) && root.items.length === 0);
|
|
98
|
+
if (empty) {
|
|
99
|
+
if (fs.existsSync(svc.filename))
|
|
100
|
+
fs.rmSync(svc.filename);
|
|
101
|
+
}
|
|
102
|
+
else
|
|
103
|
+
svc.write();
|
|
144
104
|
}
|
|
145
|
-
|
|
105
|
+
/* spawn the current executable detached as a background service */
|
|
106
|
+
static spawnDetached(aseDir, port) {
|
|
107
|
+
fs.mkdirSync(aseDir, { recursive: true });
|
|
108
|
+
const logFile = path.join(aseDir, "service.log");
|
|
109
|
+
const fd = fs.openSync(logFile, "a");
|
|
110
|
+
const entry = fileURLToPath(new URL("./ase.js", import.meta.url));
|
|
111
|
+
const child = spawn(process.execPath, [entry, "service", "start"], {
|
|
112
|
+
detached: true,
|
|
113
|
+
env: { ...process.env, [SERVE_ENV]: "1", [PORT_ENV]: String(port) },
|
|
114
|
+
stdio: ["ignore", fd, fd]
|
|
115
|
+
});
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
return { child, logFile };
|
|
118
|
+
}
|
|
119
|
+
/* read the last N non-empty lines of a log file for diagnostics */
|
|
120
|
+
static readLogTail(logFile, lines) {
|
|
121
|
+
let fd = null;
|
|
122
|
+
try {
|
|
123
|
+
fd = fs.openSync(logFile, "r");
|
|
124
|
+
const size = fs.fstatSync(fd).size;
|
|
125
|
+
const CHUNK = 8192;
|
|
126
|
+
const buf = Buffer.alloc(CHUNK);
|
|
127
|
+
let pos = size;
|
|
128
|
+
let tail = "";
|
|
129
|
+
let count = 0;
|
|
130
|
+
while (pos > 0 && count <= lines) {
|
|
131
|
+
const n = Math.min(CHUNK, pos);
|
|
132
|
+
pos -= n;
|
|
133
|
+
fs.readSync(fd, buf, 0, n, pos);
|
|
134
|
+
tail = buf.toString("utf8", 0, n) + tail;
|
|
135
|
+
count = 0;
|
|
136
|
+
for (let i = 0; i < tail.length; i++)
|
|
137
|
+
if (tail.charCodeAt(i) === 10)
|
|
138
|
+
count++;
|
|
139
|
+
}
|
|
140
|
+
const all = tail.split("\n").filter((l) => l.length > 0);
|
|
141
|
+
return all.slice(-lines).join("\n");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
if (fd !== null)
|
|
148
|
+
fs.closeSync(fd);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/* MCP registration entry point for service-identity tools */
|
|
153
|
+
export class ServiceMCP {
|
|
154
|
+
ctx;
|
|
155
|
+
constructor(ctx) {
|
|
156
|
+
this.ctx = ctx;
|
|
157
|
+
}
|
|
158
|
+
register(mcp) {
|
|
159
|
+
mcp.registerTool("ping", {
|
|
160
|
+
title: "ASE service ping",
|
|
161
|
+
description: "Return ASE service identity, port, and uptime.",
|
|
162
|
+
inputSchema: {}
|
|
163
|
+
}, async () => {
|
|
164
|
+
const status = {
|
|
165
|
+
ok: true,
|
|
166
|
+
projectId: this.ctx.projectId,
|
|
167
|
+
port: this.ctx.port,
|
|
168
|
+
uptimeMs: Date.now() - this.ctx.startTime
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text", text: JSON.stringify(status) }]
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
mcp.registerTool("timestamp", {
|
|
175
|
+
title: "ASE timestamp",
|
|
176
|
+
description: "Return the current local date/time formatted via a Luxon format string. " +
|
|
177
|
+
"Pass the Luxon format tokens as `format` (default: `yyyy-LL-dd HH:mm`). " +
|
|
178
|
+
"Returns the formatted timestamp as `text`.",
|
|
179
|
+
inputSchema: {
|
|
180
|
+
format: z.string().default("yyyy-LL-dd HH:mm")
|
|
181
|
+
.describe("Luxon format tokens (default: `yyyy-LL-dd HH:mm`)")
|
|
182
|
+
}
|
|
183
|
+
}, async (args) => {
|
|
184
|
+
try {
|
|
185
|
+
const text = DateTime.now().toFormat(args.format);
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "text", text }]
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
+
return {
|
|
193
|
+
isError: true,
|
|
194
|
+
content: [{ type: "text", text: `timestamp: format failed: ${message}` }]
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
146
200
|
/* CLI command "ase service" */
|
|
147
201
|
export default class ServiceCommand {
|
|
148
202
|
log;
|
|
@@ -184,300 +238,13 @@ export default class ServiceCommand {
|
|
|
184
238
|
lastActivity = Date.now();
|
|
185
239
|
return h.continue;
|
|
186
240
|
});
|
|
187
|
-
/* build a fresh MCP server instance with
|
|
241
|
+
/* build a fresh MCP server instance with all registered tools */
|
|
188
242
|
const buildMcpServer = () => {
|
|
189
243
|
const mcp = new McpServer({ name: "ase", version: pkg.version });
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}, async () => {
|
|
195
|
-
const status = {
|
|
196
|
-
ok: true,
|
|
197
|
-
projectId: ctx.projectId,
|
|
198
|
-
port: ctx.port,
|
|
199
|
-
uptimeMs: Date.now() - startTime
|
|
200
|
-
};
|
|
201
|
-
return {
|
|
202
|
-
content: [{ type: "text", text: JSON.stringify(status) }]
|
|
203
|
-
};
|
|
204
|
-
});
|
|
205
|
-
mcp.registerTool("timestamp", {
|
|
206
|
-
title: "ASE timestamp",
|
|
207
|
-
description: "Return the current local date/time formatted via a Luxon format string. " +
|
|
208
|
-
"Pass the Luxon format tokens as `format` (default: `yyyy-LL-dd HH:mm`). " +
|
|
209
|
-
"Returns the formatted timestamp as `text`.",
|
|
210
|
-
inputSchema: {
|
|
211
|
-
format: z.string().default("yyyy-LL-dd HH:mm")
|
|
212
|
-
.describe("Luxon format tokens (default: `yyyy-LL-dd HH:mm`)")
|
|
213
|
-
}
|
|
214
|
-
}, async (args) => {
|
|
215
|
-
try {
|
|
216
|
-
const text = DateTime.now().toFormat(args.format);
|
|
217
|
-
return {
|
|
218
|
-
content: [{ type: "text", text }]
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
catch (err) {
|
|
222
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
-
return {
|
|
224
|
-
isError: true,
|
|
225
|
-
content: [{ type: "text", text: `timestamp: format failed: ${message}` }]
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
mcp.registerTool("diagram", {
|
|
230
|
-
title: "ASE diagram render",
|
|
231
|
-
description: "Render a Mermaid diagram as Unicode/ASCII art. " +
|
|
232
|
-
"Use for visualizing " +
|
|
233
|
-
"structure/layout/components/dependencies as a Flowchart, " +
|
|
234
|
-
"control-flow/branching/concurrency as a Flowchart, " +
|
|
235
|
-
"state-machine/states/transitions as an UML State Diagram, " +
|
|
236
|
-
"data-flow/actors/messages/protocols as an UML Sequence Diagram, " +
|
|
237
|
-
"data-structure/classes/methods as an UML Class Diagram " +
|
|
238
|
-
"data-model/entities/relationships as an ER Diagram, or " +
|
|
239
|
-
"metrics/distributions/time-series as a XY-Charts. " +
|
|
240
|
-
"Pass the Mermaid diagram specification as `diagram`. " +
|
|
241
|
-
"Returns the rendered art as `text`.",
|
|
242
|
-
inputSchema: {
|
|
243
|
-
diagram: z.string()
|
|
244
|
-
.describe("Mermaid diagram specification"),
|
|
245
|
-
ascii: z.boolean().default(false)
|
|
246
|
-
.describe("emit plain ASCII (+-|) instead of Unicode box-drawing characters"),
|
|
247
|
-
colorMode: z.enum(["none", "ansi16", "ansi256"]).default("none")
|
|
248
|
-
.describe("color mode for ANSI escape sequences in the rendered output"),
|
|
249
|
-
nodeMarginX: z.number().int().min(0).default(3)
|
|
250
|
-
.describe("horizontal margin between nodes, in characters"),
|
|
251
|
-
nodeMarginY: z.number().int().min(0).default(3)
|
|
252
|
-
.describe("vertical margin between nodes, in lines"),
|
|
253
|
-
nodePadding: z.number().int().min(0).default(1)
|
|
254
|
-
.describe("inner horizontal and vertical padding within each node, in characters"),
|
|
255
|
-
diagramClipX: z.number().int().min(0).default(0)
|
|
256
|
-
.describe("extra horizontal clipping: subtract this many characters from `terminalWidth`"),
|
|
257
|
-
diagramClipY: z.number().int().min(0).default(0)
|
|
258
|
-
.describe("extra vertical clipping: subtract this many lines from `terminalHeight`"),
|
|
259
|
-
terminalWidth: z.number().int().min(0).default(detectTermWidth())
|
|
260
|
-
.describe("terminal width in characters; 0 disables horizontal clipping; defaults to ASE_TERM_WIDTH env var if set"),
|
|
261
|
-
terminalHeight: z.number().int().min(0).default(detectTermHeight())
|
|
262
|
-
.describe("terminal height in lines; 0 disables vertical clipping; defaults to ASE_TERM_HEIGHT env var if set")
|
|
263
|
-
}
|
|
264
|
-
}, async (args) => {
|
|
265
|
-
try {
|
|
266
|
-
const out = renderDiagram(args.diagram, {
|
|
267
|
-
ascii: args.ascii,
|
|
268
|
-
colorMode: args.colorMode,
|
|
269
|
-
nodeMarginX: args.nodeMarginX,
|
|
270
|
-
nodeMarginY: args.nodeMarginY,
|
|
271
|
-
nodePadding: args.nodePadding,
|
|
272
|
-
diagramClipX: args.diagramClipX,
|
|
273
|
-
diagramClipY: args.diagramClipY,
|
|
274
|
-
terminalWidth: args.terminalWidth,
|
|
275
|
-
terminalHeight: args.terminalHeight
|
|
276
|
-
});
|
|
277
|
-
return {
|
|
278
|
-
content: [{ type: "text", text: out }]
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
283
|
-
return {
|
|
284
|
-
isError: true,
|
|
285
|
-
content: [{ type: "text", text: `diagram: render failed: ${message}` }]
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
mcp.registerTool("task_list", {
|
|
290
|
-
title: "ASE task list",
|
|
291
|
-
description: "List all persisted task `id`s. " +
|
|
292
|
-
"Returns the ids as `text`, one per line, in lexicographic order; " +
|
|
293
|
-
"returns an empty string if no tasks exist.",
|
|
294
|
-
inputSchema: {}
|
|
295
|
-
}, async () => {
|
|
296
|
-
try {
|
|
297
|
-
const ids = taskList();
|
|
298
|
-
return {
|
|
299
|
-
content: [{ type: "text", text: ids.join("\n") }]
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
catch (err) {
|
|
303
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
304
|
-
return {
|
|
305
|
-
isError: true,
|
|
306
|
-
content: [{ type: "text", text: `task_list: ERROR: ${message}` }]
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
mcp.registerTool("task_load", {
|
|
311
|
-
title: "ASE task load",
|
|
312
|
-
description: "Load a previously persisted task by `id`. " +
|
|
313
|
-
"Returns the task as `text`; returns an empty string if no task exists for the `id`.",
|
|
314
|
-
inputSchema: {
|
|
315
|
-
id: z.string()
|
|
316
|
-
.describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
|
|
317
|
-
}
|
|
318
|
-
}, async (args) => {
|
|
319
|
-
try {
|
|
320
|
-
const text = taskLoad(args.id);
|
|
321
|
-
return {
|
|
322
|
-
content: [{ type: "text", text }]
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
catch (err) {
|
|
326
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
327
|
-
return {
|
|
328
|
-
isError: true,
|
|
329
|
-
content: [{ type: "text", text: `task_load: ERROR: ${message}` }]
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
mcp.registerTool("task_save", {
|
|
334
|
-
title: "ASE task save",
|
|
335
|
-
description: "Persist a task as `text` under `id`. " +
|
|
336
|
-
"Overwrites any existing task for the same `id`.",
|
|
337
|
-
inputSchema: {
|
|
338
|
-
id: z.string()
|
|
339
|
-
.describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')"),
|
|
340
|
-
text: z.string()
|
|
341
|
-
.describe("text content of the task")
|
|
342
|
-
}
|
|
343
|
-
}, async (args) => {
|
|
344
|
-
try {
|
|
345
|
-
taskSave(args.id, args.text);
|
|
346
|
-
return {
|
|
347
|
-
content: [{ type: "text", text: `task_save: OK: saved task "${args.id}"` }]
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
catch (err) {
|
|
351
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
352
|
-
return {
|
|
353
|
-
isError: true,
|
|
354
|
-
content: [{ type: "text", text: `task_save: ERROR: ${message}` }]
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
mcp.registerTool("task_delete", {
|
|
359
|
-
title: "ASE task delete",
|
|
360
|
-
description: "Delete a previously persisted task by `id`. " +
|
|
361
|
-
"Returns a status `text` indicating whether a task existed and was removed.",
|
|
362
|
-
inputSchema: {
|
|
363
|
-
id: z.string()
|
|
364
|
-
.describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
|
|
365
|
-
}
|
|
366
|
-
}, async (args) => {
|
|
367
|
-
try {
|
|
368
|
-
const removed = taskDelete(args.id);
|
|
369
|
-
const msg = removed ?
|
|
370
|
-
`task_delete: OK: removed task "${args.id}"` :
|
|
371
|
-
`task_delete: WARNING: no task "${args.id}" to remove`;
|
|
372
|
-
return {
|
|
373
|
-
content: [{ type: "text", text: msg }]
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
378
|
-
return {
|
|
379
|
-
isError: true,
|
|
380
|
-
content: [{ type: "text", text: `task_delete: ERROR: ${message}` }]
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
mcp.registerTool("persona", {
|
|
385
|
-
title: "ASE persona style get/set",
|
|
386
|
-
description: "Get or set the active ASE agent persona `style`. " +
|
|
387
|
-
"If `style` is provided, it sets the persona style, " +
|
|
388
|
-
"otherwise it returns the current persona `style`. " +
|
|
389
|
-
"If `session` is provided, the operation is scoped to that session, " +
|
|
390
|
-
"otherwise it operates on the broadest scope (user/project cascade). " +
|
|
391
|
-
"Allowed styles: \"writer\" (decorative, eloquent, explaining), " +
|
|
392
|
-
"\"engineer\" (brief, factual, accurate), " +
|
|
393
|
-
"\"telegrapher\" (very brief, factual, abbreviating), " +
|
|
394
|
-
"\"caveman\" (ultra brief, rough, stuttering).",
|
|
395
|
-
inputSchema: {
|
|
396
|
-
style: z.enum(["writer", "engineer", "telegrapher", "caveman"]).optional()
|
|
397
|
-
.describe("persona style to set; if omitted, the current persona style is returned"),
|
|
398
|
-
session: z.string().optional()
|
|
399
|
-
.describe("session identifier (allowed characters: A-Z, a-z, 0-9, '-'); " +
|
|
400
|
-
"if omitted, the operation is not scoped to a specific session")
|
|
401
|
-
}
|
|
402
|
-
}, async (args) => {
|
|
403
|
-
try {
|
|
404
|
-
const scope = args.session !== undefined ?
|
|
405
|
-
parseScope(`session:${args.session}`) :
|
|
406
|
-
parseScope(undefined);
|
|
407
|
-
const cfg = new Config("config", configSchema, this.log, scope);
|
|
408
|
-
cfg.read();
|
|
409
|
-
if (args.style !== undefined) {
|
|
410
|
-
cfg.set("agent.persona", args.style);
|
|
411
|
-
cfg.write();
|
|
412
|
-
const where = args.session !== undefined ?
|
|
413
|
-
` for session "${args.session}"` : "";
|
|
414
|
-
const msg = `persona: OK: set agent.persona to "${args.style}"${where}`;
|
|
415
|
-
return {
|
|
416
|
-
content: [{ type: "text", text: msg }]
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
const val = cfg.get("agent.persona");
|
|
420
|
-
if (val === undefined)
|
|
421
|
-
return {
|
|
422
|
-
content: [{ type: "text", text: "engineer" }]
|
|
423
|
-
};
|
|
424
|
-
const text = String(isScalar(val) ? val.value : val);
|
|
425
|
-
return {
|
|
426
|
-
content: [{ type: "text", text }]
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
catch (err) {
|
|
430
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
431
|
-
return {
|
|
432
|
-
isError: true,
|
|
433
|
-
content: [{ type: "text", text: `persona: ERROR: ${message}` }]
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
mcp.registerTool("task_id", {
|
|
438
|
-
title: "ASE task id get/set",
|
|
439
|
-
description: "Get or set the active ASE task `id` for a given `session`. " +
|
|
440
|
-
"If `id` is provided, it sets the task id in the given `session`, " +
|
|
441
|
-
"otherwise it returns the current task `id` of the `session`.",
|
|
442
|
-
inputSchema: {
|
|
443
|
-
id: z.string().optional()
|
|
444
|
-
.describe("task identifier to set (allowed characters: A-Z, a-z, 0-9, '-'); " +
|
|
445
|
-
"if omitted, the current task id is returned"),
|
|
446
|
-
session: z.string()
|
|
447
|
-
.describe("session identifier (allowed characters: A-Z, a-z, 0-9, '-')")
|
|
448
|
-
}
|
|
449
|
-
}, async (args) => {
|
|
450
|
-
try {
|
|
451
|
-
const scope = parseScope(`session:${args.session}`);
|
|
452
|
-
const cfg = new Config("config", configSchema, this.log, scope);
|
|
453
|
-
cfg.read();
|
|
454
|
-
if (args.id !== undefined) {
|
|
455
|
-
cfg.set("agent.task", args.id);
|
|
456
|
-
cfg.write();
|
|
457
|
-
const msg = `task_id: OK: set agent.task to "${args.id}" ` +
|
|
458
|
-
`for session "${args.session}"`;
|
|
459
|
-
return {
|
|
460
|
-
content: [{ type: "text", text: msg }]
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
const val = cfg.get("agent.task");
|
|
464
|
-
if (val === undefined)
|
|
465
|
-
return {
|
|
466
|
-
content: [{ type: "text", text: "" }]
|
|
467
|
-
};
|
|
468
|
-
const text = String(isScalar(val) ? val.value : val);
|
|
469
|
-
return {
|
|
470
|
-
content: [{ type: "text", text }]
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
catch (err) {
|
|
474
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
475
|
-
return {
|
|
476
|
-
isError: true,
|
|
477
|
-
content: [{ type: "text", text: `task_id: ERROR: ${message}` }]
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
});
|
|
244
|
+
new ServiceMCP({ projectId: ctx.projectId, port: ctx.port, startTime }).register(mcp);
|
|
245
|
+
new DiagramMCP().register(mcp);
|
|
246
|
+
new TaskMCP(this.log).register(mcp);
|
|
247
|
+
new PersonaMCP(this.log).register(mcp);
|
|
481
248
|
return mcp;
|
|
482
249
|
};
|
|
483
250
|
/* listen to HTTP/REST endpoints */
|
|
@@ -571,7 +338,7 @@ export default class ServiceCommand {
|
|
|
571
338
|
/* start service */
|
|
572
339
|
try {
|
|
573
340
|
await server.start();
|
|
574
|
-
persistPort(ctx.svc, ctx.port);
|
|
341
|
+
Service.persistPort(ctx.svc, ctx.port);
|
|
575
342
|
this.log.write("info", `service: listening on port ${ctx.port}`);
|
|
576
343
|
}
|
|
577
344
|
catch (err) {
|
|
@@ -582,7 +349,7 @@ export default class ServiceCommand {
|
|
|
582
349
|
if (match === true)
|
|
583
350
|
process.exit(0);
|
|
584
351
|
this.log.write("error", `service: port ${ctx.port} in use, but not responding!`);
|
|
585
|
-
clearPort(ctx.svc);
|
|
352
|
+
Service.clearPort(ctx.svc);
|
|
586
353
|
process.exit(1);
|
|
587
354
|
}
|
|
588
355
|
this.log.write("error", `service: ${e.message}`);
|
|
@@ -597,13 +364,13 @@ export default class ServiceCommand {
|
|
|
597
364
|
this.log.write("info", "service: idle timeout reached, stopping");
|
|
598
365
|
try {
|
|
599
366
|
await server.stop({ timeout: 1000 });
|
|
600
|
-
clearPort(ctx.svc);
|
|
367
|
+
Service.clearPort(ctx.svc);
|
|
601
368
|
process.exit(0);
|
|
602
369
|
}
|
|
603
370
|
catch (err) {
|
|
604
371
|
const e = err;
|
|
605
372
|
this.log.write("error", `service: stop failed: ${e.message}`);
|
|
606
|
-
clearPort(ctx.svc);
|
|
373
|
+
Service.clearPort(ctx.svc);
|
|
607
374
|
process.exit(1);
|
|
608
375
|
}
|
|
609
376
|
}
|
|
@@ -615,7 +382,7 @@ export default class ServiceCommand {
|
|
|
615
382
|
let port = ctx.port;
|
|
616
383
|
if (process.env[SERVE_ENV] === "1") {
|
|
617
384
|
const raw = process.env[PORT_ENV];
|
|
618
|
-
port = raw !== undefined ? Number(raw) : await allocatePort();
|
|
385
|
+
port = raw !== undefined ? Number(raw) : await Service.allocatePort();
|
|
619
386
|
await this.runService({ ...ctx, port });
|
|
620
387
|
return await new Promise(() => { });
|
|
621
388
|
}
|
|
@@ -630,8 +397,8 @@ export default class ServiceCommand {
|
|
|
630
397
|
re-allocate, re-persist, re-spawn; early-break on foreign listener */
|
|
631
398
|
let lastErr = new Error("service failed to start within timeout");
|
|
632
399
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
633
|
-
port = await allocatePort();
|
|
634
|
-
const { child, logFile } = spawnDetached(ctx.aseDir, port);
|
|
400
|
+
port = await Service.allocatePort();
|
|
401
|
+
const { child, logFile } = Service.spawnDetached(ctx.aseDir, port);
|
|
635
402
|
let exited = false;
|
|
636
403
|
let exitCode = null;
|
|
637
404
|
let resolveExit = () => { };
|
|
@@ -663,7 +430,7 @@ export default class ServiceCommand {
|
|
|
663
430
|
break;
|
|
664
431
|
}
|
|
665
432
|
}
|
|
666
|
-
const tail = readLogTail(logFile, 20);
|
|
433
|
+
const tail = Service.readLogTail(logFile, 20);
|
|
667
434
|
const reason = exited ?
|
|
668
435
|
`service exited during startup (code ${exitCode})` :
|
|
669
436
|
foreign ?
|
|
@@ -687,7 +454,7 @@ export default class ServiceCommand {
|
|
|
687
454
|
}
|
|
688
455
|
}
|
|
689
456
|
}
|
|
690
|
-
clearPort(ctx.svc);
|
|
457
|
+
Service.clearPort(ctx.svc);
|
|
691
458
|
throw lastErr;
|
|
692
459
|
}
|
|
693
460
|
/* status flow: report whether the service is running */
|
|
@@ -766,7 +533,7 @@ export default class ServiceCommand {
|
|
|
766
533
|
}
|
|
767
534
|
if (match === null) {
|
|
768
535
|
this.log.write("info", `service: not running (port ${ctx.port} not responding)`);
|
|
769
|
-
clearPort(ctx.svc);
|
|
536
|
+
Service.clearPort(ctx.svc);
|
|
770
537
|
return 0;
|
|
771
538
|
}
|
|
772
539
|
const r = await axios.request({
|
|
@@ -776,7 +543,7 @@ export default class ServiceCommand {
|
|
|
776
543
|
validateStatus: () => true
|
|
777
544
|
});
|
|
778
545
|
const ok = r.status >= 200 && r.status < 300;
|
|
779
|
-
clearPort(ctx.svc);
|
|
546
|
+
Service.clearPort(ctx.svc);
|
|
780
547
|
return ok ? 0 : 1;
|
|
781
548
|
}
|
|
782
549
|
/* register commands */
|