@rse/ase 0.9.8 → 0.9.9

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.
Files changed (67) hide show
  1. package/dst/ase-getopt.js +60 -4
  2. package/dst/ase-hook.js +6 -21
  3. package/dst/ase-markdown.js +32 -11
  4. package/dst/ase-mcp.js +22 -8
  5. package/dst/ase-notify.js +32 -0
  6. package/dst/ase-service.js +5 -2
  7. package/dst/ase-setup.js +45 -131
  8. package/dst/ase-skills.js +17 -13
  9. package/dst/ase-statusline.js +8 -12
  10. package/dst/ase-task.js +12 -2
  11. package/package.json +2 -2
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.github/plugin/plugin.json +1 -1
  14. package/plugin/meta/ase-dialog.md +105 -7
  15. package/plugin/meta/ase-getopt.md +31 -23
  16. package/plugin/meta/ase-skill.md +62 -6
  17. package/plugin/package.json +1 -1
  18. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  19. package/plugin/skills/ase-arch-discover/SKILL.md +40 -27
  20. package/plugin/skills/ase-arch-discover/help.md +1 -1
  21. package/plugin/skills/ase-code-analyze/help.md +2 -2
  22. package/plugin/skills/ase-code-craft/SKILL.md +43 -38
  23. package/plugin/skills/ase-code-craft/help.md +2 -2
  24. package/plugin/skills/ase-code-explain/help.md +1 -1
  25. package/plugin/skills/ase-code-insight/help.md +1 -1
  26. package/plugin/skills/ase-code-lint/SKILL.md +20 -11
  27. package/plugin/skills/ase-code-lint/help.md +2 -2
  28. package/plugin/skills/ase-code-refactor/SKILL.md +42 -38
  29. package/plugin/skills/ase-code-refactor/help.md +2 -2
  30. package/plugin/skills/ase-code-resolve/SKILL.md +42 -37
  31. package/plugin/skills/ase-code-resolve/help.md +3 -3
  32. package/plugin/skills/ase-docs-distill/help.md +1 -1
  33. package/plugin/skills/ase-docs-proofread/SKILL.md +18 -10
  34. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  35. package/plugin/skills/ase-meta-brainstorm/SKILL.md +5 -3
  36. package/plugin/skills/ase-meta-brainstorm/help.md +1 -1
  37. package/plugin/skills/ase-meta-changelog/help.md +1 -1
  38. package/plugin/skills/ase-meta-chat/help.md +1 -1
  39. package/plugin/skills/ase-meta-commit/help.md +1 -1
  40. package/plugin/skills/ase-meta-diaboli/help.md +2 -2
  41. package/plugin/skills/ase-meta-diff/SKILL.md +1 -1
  42. package/plugin/skills/ase-meta-diff/help.md +1 -1
  43. package/plugin/skills/ase-meta-evaluate/help.md +2 -2
  44. package/plugin/skills/ase-meta-persona/help.md +1 -1
  45. package/plugin/skills/ase-meta-quorum/help.md +1 -1
  46. package/plugin/skills/ase-meta-review/SKILL.md +0 -1
  47. package/plugin/skills/ase-meta-review/help.md +2 -2
  48. package/plugin/skills/ase-meta-search/help.md +1 -1
  49. package/plugin/skills/ase-meta-steelman/help.md +2 -2
  50. package/plugin/skills/ase-meta-why/help.md +1 -1
  51. package/plugin/skills/ase-task-condense/SKILL.md +4 -2
  52. package/plugin/skills/ase-task-condense/help.md +2 -2
  53. package/plugin/skills/ase-task-delete/help.md +2 -2
  54. package/plugin/skills/ase-task-edit/SKILL.md +8 -4
  55. package/plugin/skills/ase-task-edit/help.md +2 -2
  56. package/plugin/skills/ase-task-grill/SKILL.md +4 -2
  57. package/plugin/skills/ase-task-grill/help.md +3 -3
  58. package/plugin/skills/ase-task-id/help.md +2 -2
  59. package/plugin/skills/ase-task-implement/SKILL.md +4 -2
  60. package/plugin/skills/ase-task-implement/help.md +2 -2
  61. package/plugin/skills/ase-task-list/help.md +2 -2
  62. package/plugin/skills/ase-task-preflight/SKILL.md +4 -2
  63. package/plugin/skills/ase-task-preflight/help.md +2 -2
  64. package/plugin/skills/ase-task-reboot/SKILL.md +4 -2
  65. package/plugin/skills/ase-task-reboot/help.md +2 -2
  66. package/plugin/skills/ase-task-rename/help.md +2 -2
  67. package/plugin/skills/ase-task-view/help.md +2 -2
package/dst/ase-getopt.js CHANGED
@@ -56,6 +56,7 @@ export class GetoptMCP {
56
56
  const tokens = args.spec.split(/\s+/).filter((e) => e.length > 0);
57
57
  const re = /^--([A-Za-z][A-Za-z0-9-]*)(?:\|-([A-Za-z]))?(?:=(\((.*)\)(\.\.\.)?|.*))?$/;
58
58
  const internals = new Set();
59
+ const flagTakesValue = new Map();
59
60
  for (const tok of tokens) {
60
61
  const m = re.exec(tok);
61
62
  if (m === null)
@@ -69,6 +70,9 @@ export class GetoptMCP {
69
70
  const choices = choicePart !== null ? choicePart.split("|") : null;
70
71
  const isList = listMarker !== null;
71
72
  const dflt = choices !== null ? choices[0] : valuePart;
73
+ flagTakesValue.set(`--${long}`, takesValue);
74
+ if (short !== null)
75
+ flagTakesValue.set(`-${short}`, takesValue);
72
76
  const head = short !== null ? `-${short}, --${long}` : `--${long}`;
73
77
  const flags = takesValue ? `${head} <value>` : head;
74
78
  const opt = new Option(flags);
@@ -143,11 +147,63 @@ export class GetoptMCP {
143
147
  }
144
148
  ranges.push({ start, end: i });
145
149
  }
146
- const consumed = argsVec.length - cmd.args.length;
147
- if (cmd.args.length > 0 && consumed >= 0 && consumed < ranges.length) {
148
- const first = ranges[consumed].start;
149
- argsVerbatim = argsRaw.slice(first);
150
+ /* helper function: strip surrounding quotes/escapes from a raw
151
+ range so it can be compared against an option flag spelling */
152
+ const unquote = (s) => {
153
+ let out = "";
154
+ let j = 0;
155
+ while (j < s.length) {
156
+ const ch = s[j];
157
+ if (ch === "\"" || ch === "'") {
158
+ const quote = ch;
159
+ j++;
160
+ while (j < s.length && s[j] !== quote) {
161
+ if (s[j] === "\\" && j + 1 < s.length) {
162
+ out += s[j + 1];
163
+ j += 2;
164
+ }
165
+ else {
166
+ out += s[j];
167
+ j++;
168
+ }
169
+ }
170
+ j++;
171
+ }
172
+ else if (ch === "\\" && j + 1 < s.length) {
173
+ out += s[j + 1];
174
+ j += 2;
175
+ }
176
+ else {
177
+ out += ch;
178
+ j++;
179
+ }
180
+ }
181
+ return out;
182
+ };
183
+ /* walk the raw ranges, consuming leading option tokens (and any
184
+ separate value tokens they take) until the first positional
185
+ is reached, then slice the original input from there -- this
186
+ mirrors commander's pass-through semantics while staying on
187
+ the verbatim text and is robust against value-consuming
188
+ options and shell-operator characters in the input */
189
+ let idx = 0;
190
+ while (idx < ranges.length) {
191
+ const tok = unquote(argsRaw.slice(ranges[idx].start, ranges[idx].end));
192
+ if (tok === "--") {
193
+ idx++;
194
+ break;
195
+ }
196
+ if (!tok.startsWith("-") || tok === "-")
197
+ break;
198
+ let consumesNext = false;
199
+ if (/^--[^=]+$/.test(tok) || /^-[^-]$/.test(tok))
200
+ consumesNext = flagTakesValue.get(tok) === true;
201
+ idx++;
202
+ if (consumesNext && idx < ranges.length)
203
+ idx++;
150
204
  }
205
+ if (idx < ranges.length)
206
+ argsVerbatim = argsRaw.slice(ranges[idx].start);
151
207
  }
152
208
  else
153
209
  argsVerbatim = cmd.args.join(" ");
package/dst/ase-hook.js CHANGED
@@ -92,7 +92,7 @@ export default class HookCommand {
92
92
  return text.replace(/@(\S+)/g, (match, ref) => {
93
93
  let resolved = ref;
94
94
  if (resolved.startsWith("~/"))
95
- resolved = path.join(process.env.HOME ?? "", resolved.slice(2));
95
+ resolved = path.join(os.homedir(), resolved.slice(2));
96
96
  const abs = path.isAbsolute(resolved) ? resolved : path.resolve(baseDir, resolved);
97
97
  if (visited.has(abs))
98
98
  return match;
@@ -265,24 +265,6 @@ export default class HookCommand {
265
265
  /* handler for "ase hook stop" (both tools) */
266
266
  doStop(_tool) {
267
267
  this.writeAgentStatus("ready");
268
- /* safety net: clear any lingering "agent.skill" marker so a
269
- crashed or aborted skill loop does not leave information active */
270
- const sessionId = this.readSessionIdFromStdin();
271
- if (this.isValidSessionId(sessionId)) {
272
- try {
273
- const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
274
- cfg.lock(() => {
275
- cfg.read();
276
- if (typeof cfg.get("agent.skill") === "string") {
277
- cfg.delete("agent.skill");
278
- cfg.write();
279
- }
280
- });
281
- }
282
- catch (_e) {
283
- /* best-effort: ignore failures */
284
- }
285
- }
286
268
  return 0;
287
269
  }
288
270
  /* handler for "ase hook session-end" (both tools) */
@@ -334,6 +316,9 @@ export default class HookCommand {
334
316
  return "";
335
317
  }
336
318
  }
319
+ /* the edit-capable skills whose active state lets the pre-tool-use
320
+ hook auto-approve subsequent "Edit" invocations */
321
+ editCapableSkills = ["ase-code-lint", "ase-docs-proofread"];
337
322
  /* handler for "ase hook pre-tool-use" (both tools) */
338
323
  doPreToolUse(tool) {
339
324
  const spec = toolSpecs[tool];
@@ -377,9 +362,9 @@ export default class HookCommand {
377
362
  else if (toolName === "Edit") {
378
363
  const sessionId = this.pickSessionId(input);
379
364
  const activeSkill = this.readActiveSkill(sessionId);
380
- if (activeSkill === "ase-docs-proofread" || activeSkill === "ase-code-lint") {
365
+ if (this.editCapableSkills.includes(activeSkill)) {
381
366
  approve = true;
382
- reason = `${activeSkill}: user already consented via AskUserQuestion`;
367
+ reason = `${activeSkill}: edit auto-approved for active edit-capable skill`;
383
368
  }
384
369
  }
385
370
  /* emit permission decision (or stay silent to defer to default flow).
@@ -65,8 +65,8 @@ export class Markdown {
65
65
  /* apply the inline-span and bullet-marker rewriting passes to a chunk of
66
66
  non-fenced Markdown text (see prepare() for fenced-block handling) */
67
67
  static rewrite(text) {
68
- /* PASS 1: rewrite single-backtick inline code spans that carry
69
- backslash-escaped backticks (`\``) into CommonMark-correct spans. */
68
+ /* PASS 1: rewrite inline code spans that carry backslash-escaped
69
+ backticks (`\``) into CommonMark-correct spans. */
70
70
  {
71
71
  let pre = "";
72
72
  let j = 0;
@@ -79,10 +79,19 @@ export class Markdown {
79
79
  j++;
80
80
  continue;
81
81
  }
82
- /* scan an opening single-backtick span, capturing its raw
83
- inner content up to the matching unescaped close backtick */
82
+ /* measure the full opening backtick run: a code-span
83
+ delimiter is a *run* of backticks and a span opened by a
84
+ run of N backticks is closed only by a run of exactly N
85
+ backticks, so the opening-run length determines what we
86
+ scan for as the closing delimiter */
87
+ let open = 0;
88
+ while (j + open < text.length && text[j + open] === "`")
89
+ open++;
90
+ /* scan the opening backtick-run span, capturing its raw
91
+ inner content up to the matching unescaped closing run of
92
+ exactly `open` backticks */
84
93
  let inner = "";
85
- let k = j + 1;
94
+ let k = j + open;
86
95
  let closed = false;
87
96
  let escaped = false;
88
97
  while (k < text.length) {
@@ -94,17 +103,29 @@ export class Markdown {
94
103
  continue;
95
104
  }
96
105
  if (c === "`") {
97
- closed = true;
98
- break;
106
+ /* measure this backtick run and treat it as the
107
+ closing delimiter only if it matches the opening
108
+ run length exactly; a shorter or longer run is
109
+ literal content of the span */
110
+ let runLen = 0;
111
+ while (k + runLen < text.length && text[k + runLen] === "`")
112
+ runLen++;
113
+ if (runLen === open) {
114
+ closed = true;
115
+ break;
116
+ }
117
+ inner += "`".repeat(runLen);
118
+ k += runLen;
119
+ continue;
99
120
  }
100
121
  inner += c;
101
122
  k++;
102
123
  }
103
124
  if (!closed || !escaped) {
104
- /* not an escaped-backtick span: emit the opening backtick
125
+ /* not an escaped-backtick span: emit the opening run
105
126
  verbatim and continue scanning from just after it */
106
- pre += "`";
107
- j++;
127
+ pre += "`".repeat(open);
128
+ j += open;
108
129
  continue;
109
130
  }
110
131
  /* un-escape inner `\`` into literal backticks, then choose a
@@ -124,7 +145,7 @@ export class Markdown {
124
145
  const fence = "`".repeat(maxRun + 1);
125
146
  const pad = (content.startsWith("`") || content.endsWith("`")) ? " " : "";
126
147
  pre += `${fence}${pad}${content}${pad}${fence}`;
127
- j = k + 1;
148
+ j = k + open;
128
149
  }
129
150
  text = pre;
130
151
  }
package/dst/ase-mcp.js CHANGED
@@ -62,15 +62,21 @@ export default class MCPCommand {
62
62
  const server = new StdioServerTransport();
63
63
  /* track active client and bridge-level closed state */
64
64
  let client = null;
65
- let closedByUs = false; /* set when we initiated the client close */
66
65
  let bridgeDone = false; /* set when stdio side closes */
67
66
  let reconnecting = false; /* set while a reconnect chain is active */
67
+ /* mark the individual transports we intentionally closed, so their
68
+ onclose is not mistaken for an unexpected connection loss; using
69
+ a per-transport set (instead of a single bridge-wide flag) avoids
70
+ suppressing a legitimate retry when a freshly-created connection
71
+ fails during the brief window right after we initiated a close */
72
+ const closedByUs = new WeakSet();
68
73
  /* cleanly shut down the whole bridge */
69
74
  const shutdown = async () => {
70
75
  if (bridgeDone)
71
76
  return;
72
77
  bridgeDone = true;
73
- closedByUs = true;
78
+ if (client !== null)
79
+ closedByUs.add(client);
74
80
  const timeout = new Promise((resolve) => setTimeout(resolve, 3000));
75
81
  await Promise.race([
76
82
  Promise.allSettled([server.close(), client?.close()]),
@@ -92,7 +98,7 @@ export default class MCPCommand {
92
98
  };
93
99
  /* service closed the connection — try to recover */
94
100
  next.onclose = () => {
95
- if (client !== next || closedByUs || bridgeDone || reconnecting)
101
+ if (client !== next || closedByUs.has(next) || bridgeDone || reconnecting)
96
102
  return;
97
103
  triggerReconnect("http connection lost");
98
104
  };
@@ -110,15 +116,17 @@ export default class MCPCommand {
110
116
  try {
111
117
  const ctx = await this.ensureService();
112
118
  port = ctx.port;
113
- closedByUs = true;
114
- await client?.close();
119
+ const stale = client;
120
+ client = null;
121
+ if (stale !== null) {
122
+ closedByUs.add(stale);
123
+ await stale.close();
124
+ }
115
125
  await connectClient();
116
- closedByUs = false;
117
126
  reconnecting = false;
118
127
  this.log.write("info", "mcp: reconnected to service");
119
128
  }
120
129
  catch (err) {
121
- closedByUs = false;
122
130
  this.log.write("error", `mcp: reconnect failed: ${this.asError(err).message}`);
123
131
  reconnect(attempt + 1).catch(() => { });
124
132
  }
@@ -143,7 +151,13 @@ export default class MCPCommand {
143
151
  };
144
152
  /* start server and initial client */
145
153
  await server.start();
146
- await connectClient();
154
+ try {
155
+ await connectClient();
156
+ }
157
+ catch (err) {
158
+ /* service vanished between probe and connect — recover instead of crashing */
159
+ triggerReconnect(`initial connect failed: ${this.asError(err).message}`);
160
+ }
147
161
  /* periodically probe the service; trigger reconnect if it is gone */
148
162
  const HEALTH_INTERVAL_MS = 30_000;
149
163
  const healthTimer = setInterval(async () => {
@@ -0,0 +1,32 @@
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
+ /* MCP registration entry point for user notification tool */
8
+ export class NotifyMCP {
9
+ register(mcp) {
10
+ mcp.registerTool("ase_notify", {
11
+ title: "ASE user notification",
12
+ description: "Display a `message` to the user in the terminal of the agent harness. " +
13
+ "The display happens deterministically through the accompanying PostToolUse hook, " +
14
+ "independent of any assistant text output. " +
15
+ "Use this to surface intermediate skill progress or status information " +
16
+ "which otherwise would not be visible to the user before the final response. " +
17
+ "Returns a status `text` acknowledging the notification.",
18
+ inputSchema: {
19
+ message: z.string().min(1).max(4096)
20
+ .describe("message to display to the user (plain text, up to 4096 characters)")
21
+ }
22
+ }, async (args) => {
23
+ /* the tool itself is a deliberate no-op: the user-visible display
24
+ happens in the PostToolUse hook ("ase hook post-tool-use"),
25
+ which receives this tool call's input from the agent harness
26
+ and reflects the message back as a "systemMessage" */
27
+ return {
28
+ content: [{ type: "text", text: "notify: OK: message displayed to user" }]
29
+ };
30
+ });
31
+ }
32
+ }
@@ -43,7 +43,7 @@ export const isConnRefused = (err) => {
43
43
  /* probe the service and verify ASE identity banner */
44
44
  export const probe = async (port, projectId) => {
45
45
  try {
46
- const r = await ofetch.raw(`http://${SERVICE_HOST}:${port}/`, {
46
+ const r = await ofetch.raw(`http://${HOST}:${port}/`, {
47
47
  method: "OPTIONS",
48
48
  signal: AbortSignal.timeout(2000),
49
49
  ignoreResponseError: true
@@ -509,12 +509,15 @@ export default class ServiceCommand {
509
509
  if (ctx.port === null)
510
510
  throw new Error("service not running (no port configured after auto-start)");
511
511
  }
512
- const match = await probe(ctx.port, ctx.projectId);
512
+ let match = await probe(ctx.port, ctx.projectId);
513
513
  if (match !== true) {
514
514
  await this.doStart();
515
515
  ctx = this.loadContext();
516
516
  if (ctx.port === null)
517
517
  throw new Error("service not running (no port configured after auto-start)");
518
+ match = await probe(ctx.port, ctx.projectId);
519
+ if (match !== true)
520
+ throw new Error(`service not responding on port ${ctx.port} after auto-start`);
518
521
  }
519
522
  const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, {
520
523
  method: "POST",
package/dst/ase-setup.js CHANGED
@@ -107,11 +107,19 @@ export default class SetupCommand {
107
107
  try {
108
108
  if (quiet) {
109
109
  const result = await execa(cmd, args, { stdio: "ignore", cwd, reject: false });
110
- if (typeof result.exitCode === "number" && result.exitCode !== 0 && !final) {
111
- this.log.write("info", `setup: attempt ${i + 1}/${retries} failed for "${cmd} ${args.join(" ")}" ` +
112
- `(exit code: ${result.exitCode}): retrying...`);
113
- await new Promise((resolve) => setTimeout(resolve, 1000));
114
- continue;
110
+ if (typeof result.exitCode === "number" && result.exitCode !== 0) {
111
+ if (!final) {
112
+ this.log.write("info", `setup: attempt ${i + 1}/${retries} failed for "${cmd} ${args.join(" ")}" ` +
113
+ `(exit code: ${result.exitCode}): retrying...`);
114
+ await new Promise((resolve) => setTimeout(resolve, 1000));
115
+ continue;
116
+ }
117
+ if (ignoreError !== undefined) {
118
+ this.log.write("info", `setup: ${ignoreError} (skipped)`);
119
+ return;
120
+ }
121
+ this.log.write("error", `setup: command failed: exit code: ${result.exitCode}`);
122
+ throw new Error(`command "${cmd} ${args.join(" ")}" failed (exit code: ${result.exitCode})`);
115
123
  }
116
124
  return;
117
125
  }
@@ -386,6 +394,32 @@ export default class SetupCommand {
386
394
  ["mcp", "remove", name];
387
395
  await this.run(toolSpecs[tool].cli, args, { ignoreError: `MCP server "${name}" not registered` });
388
396
  }
397
+ /* build a chat-model MCP handler from the per-model direct and
398
+ OPENROUTER url/api/model triples, factoring out the shared
399
+ mcp-to-openai stdio scaffold common to all chat-model servers */
400
+ chatMcpHandler(direct, router) {
401
+ return async (spec, tool, action, envKey, envVal) => {
402
+ if (action === "activate")
403
+ await this.mcpAdd(tool, spec.server, { OPENAI_KEY: envVal }, {
404
+ type: "stdio", command: [
405
+ "npx", "-y", "mcp-to-openai",
406
+ "--service", spec.name,
407
+ "--mcp-tool", "query",
408
+ ...(envKey === "OPENROUTER" ? [
409
+ "--openai-url", "https://openrouter.ai/api/v1",
410
+ "--openai-api", "completion",
411
+ "--openai-model", router.model
412
+ ] : [
413
+ "--openai-url", direct.url,
414
+ "--openai-api", direct.api,
415
+ "--openai-model", direct.model
416
+ ])
417
+ ]
418
+ });
419
+ else
420
+ await this.mcpRemove(tool, spec.server);
421
+ };
422
+ }
389
423
  /* registry of pre-defined MCP servers: maps each server id onto its
390
424
  dedicated handler which performs the activate/deactivate operation */
391
425
  mcpServers = [
@@ -396,27 +430,7 @@ export default class SetupCommand {
396
430
  env: ["OPENAI_CHATGPT", "OPENROUTER"],
397
431
  server: "chat-openai-chatgpt",
398
432
  skills: ["ase-meta-chat", "ase-meta-quorum"],
399
- handler: async (spec, tool, action, envKey, envVal) => {
400
- if (action === "activate")
401
- await this.mcpAdd(tool, spec.server, { OPENAI_KEY: envVal }, {
402
- type: "stdio", command: [
403
- "npx", "-y", "mcp-to-openai",
404
- "--service", spec.name,
405
- "--mcp-tool", "query",
406
- ...(envKey === "OPENROUTER" ? [
407
- "--openai-url", "https://openrouter.ai/api/v1",
408
- "--openai-api", "completion",
409
- "--openai-model", "openai/gpt-5.5"
410
- ] : [
411
- "--openai-url", "https://api.openai.com/v1",
412
- "--openai-api", "responses",
413
- "--openai-model", "gpt-5.5"
414
- ])
415
- ]
416
- });
417
- else
418
- await this.mcpRemove(tool, spec.server);
419
- }
433
+ handler: this.chatMcpHandler({ url: "https://api.openai.com/v1", api: "responses", model: "gpt-5.5" }, { model: "openai/gpt-5.5" })
420
434
  },
421
435
  {
422
436
  id: "google-gemini",
@@ -425,27 +439,7 @@ export default class SetupCommand {
425
439
  env: ["GOOGLE_GEMINI", "OPENROUTER"],
426
440
  server: "chat-google-gemini",
427
441
  skills: ["ase-meta-chat", "ase-meta-quorum"],
428
- handler: async (spec, tool, action, envKey, envVal) => {
429
- if (action === "activate")
430
- await this.mcpAdd(tool, spec.server, { OPENAI_KEY: envVal }, {
431
- type: "stdio", command: [
432
- "npx", "-y", "mcp-to-openai",
433
- "--service", spec.name,
434
- "--mcp-tool", "query",
435
- ...(envKey === "OPENROUTER" ? [
436
- "--openai-url", "https://openrouter.ai/api/v1",
437
- "--openai-api", "completion",
438
- "--openai-model", "google/gemini-3.5-flash"
439
- ] : [
440
- "--openai-url", "https://generativelanguage.googleapis.com/v1beta/openai/",
441
- "--openai-api", "completion",
442
- "--openai-model", "gemini-3.5-flash"
443
- ])
444
- ]
445
- });
446
- else
447
- await this.mcpRemove(tool, spec.server);
448
- }
442
+ handler: this.chatMcpHandler({ url: "https://generativelanguage.googleapis.com/v1beta/openai/", api: "completion", model: "gemini-3.5-flash" }, { model: "google/gemini-3.5-flash" })
449
443
  },
450
444
  {
451
445
  id: "deepseek",
@@ -454,27 +448,7 @@ export default class SetupCommand {
454
448
  env: ["DEEPSEEK", "OPENROUTER"],
455
449
  server: "chat-deepseek",
456
450
  skills: ["ase-meta-chat", "ase-meta-quorum"],
457
- handler: async (spec, tool, action, envKey, envVal) => {
458
- if (action === "activate")
459
- await this.mcpAdd(tool, spec.server, { OPENAI_KEY: envVal }, {
460
- type: "stdio", command: [
461
- "npx", "-y", "mcp-to-openai",
462
- "--service", spec.name,
463
- "--mcp-tool", "query",
464
- ...(envKey === "OPENROUTER" ? [
465
- "--openai-url", "https://openrouter.ai/api/v1",
466
- "--openai-api", "completion",
467
- "--openai-model", "deepseek/deepseek-v4-flash"
468
- ] : [
469
- "--openai-url", "https://api.deepseek.com/v1",
470
- "--openai-api", "completion",
471
- "--openai-model", "deepseek-v4-flash"
472
- ])
473
- ]
474
- });
475
- else
476
- await this.mcpRemove(tool, spec.server);
477
- }
451
+ handler: this.chatMcpHandler({ url: "https://api.deepseek.com/v1", api: "completion", model: "deepseek-v4-flash" }, { model: "deepseek/deepseek-v4-flash" })
478
452
  },
479
453
  {
480
454
  id: "xai-grok",
@@ -483,27 +457,7 @@ export default class SetupCommand {
483
457
  env: ["XAI_GROK", "OPENROUTER"],
484
458
  server: "chat-xai-grok",
485
459
  skills: ["ase-meta-chat", "ase-meta-quorum"],
486
- handler: async (spec, tool, action, envKey, envVal) => {
487
- if (action === "activate")
488
- await this.mcpAdd(tool, spec.server, { OPENAI_KEY: envVal }, {
489
- type: "stdio", command: [
490
- "npx", "-y", "mcp-to-openai",
491
- "--service", spec.name,
492
- "--mcp-tool", "query",
493
- ...(envKey === "OPENROUTER" ? [
494
- "--openai-url", "https://openrouter.ai/api/v1",
495
- "--openai-api", "completion",
496
- "--openai-model", "x-ai/grok-4.3"
497
- ] : [
498
- "--openai-url", "https://api.x.ai/v1",
499
- "--openai-api", "completion",
500
- "--openai-model", "grok-4.3"
501
- ])
502
- ]
503
- });
504
- else
505
- await this.mcpRemove(tool, spec.server);
506
- }
460
+ handler: this.chatMcpHandler({ url: "https://api.x.ai/v1", api: "completion", model: "grok-4.3" }, { model: "x-ai/grok-4.3" })
507
461
  },
508
462
  {
509
463
  id: "alibaba-qwen",
@@ -512,27 +466,7 @@ export default class SetupCommand {
512
466
  env: ["ALIBABA_QWEN", "OPENROUTER"],
513
467
  server: "chat-alibaba-qwen",
514
468
  skills: ["ase-meta-chat", "ase-meta-quorum"],
515
- handler: async (spec, tool, action, envKey, envVal) => {
516
- if (action === "activate")
517
- await this.mcpAdd(tool, spec.server, { OPENAI_KEY: envVal }, {
518
- type: "stdio", command: [
519
- "npx", "-y", "mcp-to-openai",
520
- "--service", spec.name,
521
- "--mcp-tool", "query",
522
- ...(envKey === "OPENROUTER" ? [
523
- "--openai-url", "https://openrouter.ai/api/v1",
524
- "--openai-api", "completion",
525
- "--openai-model", "qwen/qwen3.7-max"
526
- ] : [
527
- "--openai-url", "https://dashscope.aliyuncs.com/compatible-mode/v1",
528
- "--openai-api", "completion",
529
- "--openai-model", "qwen3.7-max"
530
- ])
531
- ]
532
- });
533
- else
534
- await this.mcpRemove(tool, spec.server);
535
- }
469
+ handler: this.chatMcpHandler({ url: "https://dashscope.aliyuncs.com/compatible-mode/v1", api: "completion", model: "qwen3.7-max" }, { model: "qwen/qwen3.7-max" })
536
470
  },
537
471
  {
538
472
  id: "zai-glm",
@@ -541,27 +475,7 @@ export default class SetupCommand {
541
475
  env: ["ZAI_GLM", "OPENROUTER"],
542
476
  server: "chat-zai-glm",
543
477
  skills: ["ase-meta-chat", "ase-meta-quorum"],
544
- handler: async (spec, tool, action, envKey, envVal) => {
545
- if (action === "activate")
546
- await this.mcpAdd(tool, spec.server, { OPENAI_KEY: envVal }, {
547
- type: "stdio", command: [
548
- "npx", "-y", "mcp-to-openai",
549
- "--service", spec.name,
550
- "--mcp-tool", "query",
551
- ...(envKey === "OPENROUTER" ? [
552
- "--openai-url", "https://openrouter.ai/api/v1",
553
- "--openai-api", "completion",
554
- "--openai-model", "z-ai/glm-5.1"
555
- ] : [
556
- "--openai-url", "https://api.z.ai/api/paas/v4/",
557
- "--openai-api", "completion",
558
- "--openai-model", "glm-5.1"
559
- ])
560
- ]
561
- });
562
- else
563
- await this.mcpRemove(tool, spec.server);
564
- }
478
+ handler: this.chatMcpHandler({ url: "https://api.z.ai/api/paas/v4/", api: "completion", model: "glm-5.1" }, { model: "z-ai/glm-5.1" })
565
479
  },
566
480
  {
567
481
  id: "brave",
package/dst/ase-skills.js CHANGED
@@ -242,27 +242,31 @@ export class Skills {
242
242
  return [];
243
243
  }
244
244
  /* compute composite rank score from weighted metrics:
245
- downloads x
246
- stars x
245
+ (downloads + 1) x
246
+ (stars + 1) x
247
247
  ([lifespan =] (updated - created)) x
248
248
  ([recentness =] exp(-(now - updated) / halfLife))
249
- `"N.A."` factors are treated as neutral `1` so that stacks for which
250
- a particular metric is structurally unavailable (e.g. Maven Central
251
- exposes no per-artifact download counts) can still be ranked by the
252
- remaining metrics, instead of collapsing the entire product to zero. */
249
+ Numeric count metrics are shifted by `+1` so that a genuine `0`
250
+ (e.g. a real package with zero downloads or stars) contributes a
251
+ neutral `1` instead of collapsing the entire product to zero, while
252
+ still ordering `0 < 1 < 2 ...`. The `"N.A."` sentinel (a metric that
253
+ is structurally unavailable, e.g. Maven Central exposes no
254
+ per-artifact download counts) is likewise treated as neutral `1`, so
255
+ such stacks can still be ranked by the remaining metrics. */
253
256
  static computeRank(downloads, stars, created, updated) {
254
- const d = typeof downloads === "number" ? downloads : 1;
255
- const s = typeof stars === "number" ? stars : 1;
257
+ const d = typeof downloads === "number" ? downloads + 1 : 1;
258
+ const s = typeof stars === "number" ? stars + 1 : 1;
256
259
  const cMs = created !== "" ? Date.parse(created) : NaN;
257
260
  const uMs = updated !== "" ? Date.parse(updated) : NaN;
258
- if (Number.isNaN(cMs) || Number.isNaN(uMs))
259
- return 0;
260
261
  const now = Date.now();
261
262
  const msPerDay = 1000 * 60 * 60 * 24;
262
263
  const halfLife = 365 / 2;
263
- const lifespan = Math.max(0, uMs - cMs);
264
- const ageDays = Math.max(0, (now - uMs) / msPerDay);
265
- const recentness = Math.exp(-ageDays / halfLife);
264
+ /* lifespan requires both timestamps; recentness requires the
265
+ updated timestamp -- any unavailable date-derived factor is
266
+ treated as neutral `1` so the entry can still be ranked by the
267
+ remaining metrics, instead of collapsing the product to zero */
268
+ const lifespan = (!Number.isNaN(cMs) && !Number.isNaN(uMs)) ? Math.max(0, uMs - cMs) : 1;
269
+ const recentness = !Number.isNaN(uMs) ? Math.exp(-Math.max(0, (now - uMs) / msPerDay) / halfLife) : 1;
266
270
  return d * s * lifespan * recentness;
267
271
  }
268
272
  /* compute the per-alternative product-sum (rating) row from a