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