@cubis/foundry 0.3.40 → 0.3.42

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 (29) hide show
  1. package/README.md +67 -3
  2. package/bin/cubis.js +360 -26
  3. package/mcp/README.md +72 -8
  4. package/mcp/config.json +3 -0
  5. package/mcp/dist/index.js +315 -68
  6. package/mcp/src/config/index.test.ts +1 -0
  7. package/mcp/src/config/schema.ts +5 -0
  8. package/mcp/src/index.ts +40 -9
  9. package/mcp/src/server.ts +66 -10
  10. package/mcp/src/telemetry/tokenBudget.ts +114 -0
  11. package/mcp/src/tools/index.ts +7 -0
  12. package/mcp/src/tools/skillBrowseCategory.ts +22 -5
  13. package/mcp/src/tools/skillBudgetReport.ts +128 -0
  14. package/mcp/src/tools/skillGet.ts +18 -0
  15. package/mcp/src/tools/skillListCategories.ts +19 -6
  16. package/mcp/src/tools/skillSearch.ts +22 -5
  17. package/mcp/src/tools/skillTools.test.ts +61 -9
  18. package/mcp/src/vault/manifest.test.ts +19 -1
  19. package/mcp/src/vault/manifest.ts +12 -1
  20. package/mcp/src/vault/scanner.test.ts +1 -0
  21. package/mcp/src/vault/scanner.ts +1 -0
  22. package/mcp/src/vault/types.ts +6 -0
  23. package/package.json +1 -1
  24. package/workflows/workflows/agent-environment-setup/platforms/antigravity/rules/GEMINI.md +28 -0
  25. package/workflows/workflows/agent-environment-setup/platforms/codex/rules/AGENTS.md +31 -2
  26. package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/AGENTS.md +28 -0
  27. package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/copilot-instructions.md +28 -0
  28. package/workflows/workflows/agent-environment-setup/platforms/cursor/rules/.cursorrules +28 -0
  29. package/workflows/workflows/agent-environment-setup/platforms/windsurf/rules/.windsurfrules +28 -0
package/mcp/src/index.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * cubis-mcp # stdio (default)
7
7
  * cubis-mcp --transport stdio # explicit stdio
8
8
  * cubis-mcp --transport http # Streamable HTTP
9
+ * cubis-mcp --scope global # default config scope for built-in config tools
10
+ * cubis-mcp --host 0.0.0.0 --port 3100
9
11
  * cubis-mcp --scan-only # scan vault and exit
10
12
  */
11
13
 
@@ -31,13 +33,19 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
33
 
32
34
  function parseArgs(argv: string[]): {
33
35
  transport: "stdio" | "http";
36
+ scope: "auto" | "global" | "project";
34
37
  scanOnly: boolean;
35
38
  debug: boolean;
39
+ port?: number;
40
+ host?: string;
36
41
  configPath?: string;
37
42
  } {
38
43
  let transport: "stdio" | "http" = "stdio";
44
+ let scope: "auto" | "global" | "project" = "auto";
39
45
  let scanOnly = false;
40
46
  let debug = false;
47
+ let port: number | undefined;
48
+ let host: string | undefined;
41
49
  let configPath: string | undefined;
42
50
 
43
51
  for (let i = 2; i < argv.length; i++) {
@@ -52,6 +60,23 @@ function parseArgs(argv: string[]): {
52
60
  logger.error(`Unknown transport: ${val}. Use "stdio" or "http".`);
53
61
  process.exit(1);
54
62
  }
63
+ } else if (arg === "--scope" && argv[i + 1]) {
64
+ const val = argv[++i];
65
+ if (val === "auto" || val === "global" || val === "project") {
66
+ scope = val;
67
+ } else {
68
+ logger.error(`Unknown scope: ${val}. Use "auto", "global", or "project".`);
69
+ process.exit(1);
70
+ }
71
+ } else if (arg === "--port" && argv[i + 1]) {
72
+ const val = Number.parseInt(argv[++i], 10);
73
+ if (!Number.isInteger(val) || val <= 0 || val > 65535) {
74
+ logger.error(`Invalid port: ${argv[i]}. Use an integer from 1 to 65535.`);
75
+ process.exit(1);
76
+ }
77
+ port = val;
78
+ } else if (arg === "--host" && argv[i + 1]) {
79
+ host = argv[++i];
55
80
  } else if (arg === "--scan-only") {
56
81
  scanOnly = true;
57
82
  } else if (arg === "--debug") {
@@ -61,7 +86,7 @@ function parseArgs(argv: string[]): {
61
86
  }
62
87
  }
63
88
 
64
- return { transport, scanOnly, debug, configPath };
89
+ return { transport, scope, scanOnly, debug, port, host, configPath };
65
90
  }
66
91
 
67
92
  // ─── Startup banner ──────────────────────────────────────────
@@ -81,9 +106,9 @@ function printStartupBanner(
81
106
  logger.raw("└──────────────────────────────────────────────┘");
82
107
  }
83
108
 
84
- function printConfigStatus(): void {
109
+ function printConfigStatus(scope: "auto" | "global" | "project"): void {
85
110
  try {
86
- const effective = readEffectiveConfig("auto");
111
+ const effective = readEffectiveConfig(scope);
87
112
  if (!effective) {
88
113
  logger.warn(
89
114
  "cbx_config.json not found. Postman/Stitch tools will return config-not-found errors.",
@@ -135,7 +160,8 @@ async function main(): Promise<void> {
135
160
  // `index.ts` is in `<pkg>/src` during dev and `<pkg>/dist` after build.
136
161
  const basePath = path.resolve(__dirname, "..");
137
162
  const skills = await scanVaultRoots(serverConfig.vault.roots, basePath);
138
- const manifest = buildManifest(skills);
163
+ const charsPerToken = serverConfig.telemetry.charsPerToken;
164
+ const manifest = buildManifest(skills, charsPerToken);
139
165
 
140
166
  // Enrich with descriptions for faster browse/search at runtime
141
167
  await enrichWithDescriptions(
@@ -156,25 +182,30 @@ async function main(): Promise<void> {
156
182
  }
157
183
 
158
184
  // Print startup banner
185
+ const resolvedHttpPort = args.port ?? serverConfig.transport.http?.port ?? 3100;
159
186
  const transportName =
160
187
  args.transport === "http"
161
- ? `Streamable HTTP :${serverConfig.transport.http?.port ?? 3100}`
188
+ ? `Streamable HTTP :${resolvedHttpPort}`
162
189
  : "stdio";
163
190
  printStartupBanner(
164
191
  manifest.skills.length,
165
192
  manifest.categories.length,
166
193
  transportName,
167
194
  );
168
- printConfigStatus();
195
+ printConfigStatus(args.scope);
169
196
 
170
197
  // Create MCP server
171
- const mcpServer = await createServer({ config: serverConfig, manifest });
198
+ const mcpServer = await createServer({
199
+ config: serverConfig,
200
+ manifest,
201
+ defaultConfigScope: args.scope,
202
+ });
172
203
 
173
204
  // Connect transport
174
205
  if (args.transport === "http") {
175
206
  const httpOpts = {
176
- port: serverConfig.transport.http?.port ?? 3100,
177
- host: serverConfig.transport.http?.host ?? "127.0.0.1",
207
+ port: resolvedHttpPort,
208
+ host: args.host ?? serverConfig.transport.http?.host ?? "127.0.0.1",
178
209
  };
179
210
  const { transport, httpServer } = createStreamableHttpTransport(httpOpts);
180
211
  await mcpServer.connect(transport);
package/mcp/src/server.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { z } from "zod";
10
10
  import type { ServerConfig } from "./config/schema.js";
11
+ import type { ConfigScope } from "./cbxConfig/types.js";
11
12
  import type { VaultManifest } from "./vault/types.js";
12
13
  import {
13
14
  // Skill tools
@@ -27,6 +28,10 @@ import {
27
28
  skillGetDescription,
28
29
  skillGetSchema,
29
30
  handleSkillGet,
31
+ skillBudgetReportName,
32
+ skillBudgetReportDescription,
33
+ skillBudgetReportSchema,
34
+ handleSkillBudgetReport,
30
35
  // Postman tools
31
36
  postmanGetModeName,
32
37
  postmanGetModeDescription,
@@ -62,6 +67,7 @@ import {
62
67
  export interface CreateServerOptions {
63
68
  config: ServerConfig;
64
69
  manifest: VaultManifest;
70
+ defaultConfigScope?: ConfigScope | "auto";
65
71
  }
66
72
 
67
73
  function toolCallErrorResult({
@@ -95,6 +101,7 @@ function toolCallErrorResult({
95
101
  export async function createServer({
96
102
  config,
97
103
  manifest,
104
+ defaultConfigScope = "auto",
98
105
  }: CreateServerOptions): McpServer {
99
106
  const server = new McpServer({
100
107
  name: config.server.name,
@@ -102,6 +109,17 @@ export async function createServer({
102
109
  });
103
110
 
104
111
  const maxLen = config.vault.summaryMaxLength;
112
+ const charsPerToken = config.telemetry?.charsPerToken ?? 4;
113
+ const withDefaultScope = (
114
+ args: Record<string, unknown> | undefined,
115
+ ): Record<string, unknown> => {
116
+ const safeArgs = args ?? {};
117
+ return {
118
+ ...safeArgs,
119
+ scope:
120
+ typeof safeArgs.scope === "string" ? safeArgs.scope : defaultConfigScope,
121
+ };
122
+ };
105
123
 
106
124
  // ─── Skill vault tools ───────────────────────────────────────
107
125
 
@@ -109,28 +127,36 @@ export async function createServer({
109
127
  skillListCategoriesName,
110
128
  skillListCategoriesDescription,
111
129
  skillListCategoriesSchema.shape,
112
- async () => handleSkillListCategories(manifest),
130
+ async () => handleSkillListCategories(manifest, charsPerToken),
113
131
  );
114
132
 
115
133
  server.tool(
116
134
  skillBrowseCategoryName,
117
135
  skillBrowseCategoryDescription,
118
136
  skillBrowseCategorySchema.shape,
119
- async (args) => handleSkillBrowseCategory(args, manifest, maxLen),
137
+ async (args) =>
138
+ handleSkillBrowseCategory(args, manifest, maxLen, charsPerToken),
120
139
  );
121
140
 
122
141
  server.tool(
123
142
  skillSearchName,
124
143
  skillSearchDescription,
125
144
  skillSearchSchema.shape,
126
- async (args) => handleSkillSearch(args, manifest, maxLen),
145
+ async (args) => handleSkillSearch(args, manifest, maxLen, charsPerToken),
127
146
  );
128
147
 
129
148
  server.tool(
130
149
  skillGetName,
131
150
  skillGetDescription,
132
151
  skillGetSchema.shape,
133
- async (args) => handleSkillGet(args, manifest),
152
+ async (args) => handleSkillGet(args, manifest, charsPerToken),
153
+ );
154
+
155
+ server.tool(
156
+ skillBudgetReportName,
157
+ skillBudgetReportDescription,
158
+ skillBudgetReportSchema.shape,
159
+ async (args) => handleSkillBudgetReport(args, manifest, charsPerToken),
134
160
  );
135
161
 
136
162
  // ─── Postman tools ──────────────────────────────────────────
@@ -139,21 +165,36 @@ export async function createServer({
139
165
  postmanGetModeName,
140
166
  postmanGetModeDescription,
141
167
  postmanGetModeSchema.shape,
142
- async (args) => handlePostmanGetMode(args),
168
+ async (args) =>
169
+ handlePostmanGetMode(
170
+ withDefaultScope(args as Record<string, unknown>) as z.infer<
171
+ typeof postmanGetModeSchema
172
+ >,
173
+ ),
143
174
  );
144
175
 
145
176
  server.tool(
146
177
  postmanSetModeName,
147
178
  postmanSetModeDescription,
148
179
  postmanSetModeSchema.shape,
149
- async (args) => handlePostmanSetMode(args),
180
+ async (args) =>
181
+ handlePostmanSetMode(
182
+ withDefaultScope(args as Record<string, unknown>) as z.infer<
183
+ typeof postmanSetModeSchema
184
+ >,
185
+ ),
150
186
  );
151
187
 
152
188
  server.tool(
153
189
  postmanGetStatusName,
154
190
  postmanGetStatusDescription,
155
191
  postmanGetStatusSchema.shape,
156
- async (args) => handlePostmanGetStatus(args),
192
+ async (args) =>
193
+ handlePostmanGetStatus(
194
+ withDefaultScope(args as Record<string, unknown>) as z.infer<
195
+ typeof postmanGetStatusSchema
196
+ >,
197
+ ),
157
198
  );
158
199
 
159
200
  // ─── Stitch tools ──────────────────────────────────────────
@@ -162,21 +203,36 @@ export async function createServer({
162
203
  stitchGetModeName,
163
204
  stitchGetModeDescription,
164
205
  stitchGetModeSchema.shape,
165
- async (args) => handleStitchGetMode(args),
206
+ async (args) =>
207
+ handleStitchGetMode(
208
+ withDefaultScope(args as Record<string, unknown>) as z.infer<
209
+ typeof stitchGetModeSchema
210
+ >,
211
+ ),
166
212
  );
167
213
 
168
214
  server.tool(
169
215
  stitchSetProfileName,
170
216
  stitchSetProfileDescription,
171
217
  stitchSetProfileSchema.shape,
172
- async (args) => handleStitchSetProfile(args),
218
+ async (args) =>
219
+ handleStitchSetProfile(
220
+ withDefaultScope(args as Record<string, unknown>) as z.infer<
221
+ typeof stitchSetProfileSchema
222
+ >,
223
+ ),
173
224
  );
174
225
 
175
226
  server.tool(
176
227
  stitchGetStatusName,
177
228
  stitchGetStatusDescription,
178
229
  stitchGetStatusSchema.shape,
179
- async (args) => handleStitchGetStatus(args),
230
+ async (args) =>
231
+ handleStitchGetStatus(
232
+ withDefaultScope(args as Record<string, unknown>) as z.infer<
233
+ typeof stitchGetStatusSchema
234
+ >,
235
+ ),
180
236
  );
181
237
 
182
238
  // ─── Dynamic upstream passthrough tools ────────────────────
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Cubis Foundry MCP Server – deterministic token budget estimator.
3
+ *
4
+ * Estimates tokens by character count using a configurable chars/token ratio.
5
+ * This is intentionally model-agnostic and marked as estimated.
6
+ */
7
+
8
+ export const TOKEN_ESTIMATOR_VERSION = "char-estimator-v1";
9
+
10
+ export function normalizeCharsPerToken(value: number): number {
11
+ if (!Number.isFinite(value) || value <= 0) return 4;
12
+ return value;
13
+ }
14
+
15
+ export function estimateTokensFromCharCount(
16
+ charCount: number,
17
+ charsPerToken: number,
18
+ ): number {
19
+ const safeChars = Math.max(0, Math.ceil(charCount));
20
+ const ratio = normalizeCharsPerToken(charsPerToken);
21
+ return Math.ceil(safeChars / ratio);
22
+ }
23
+
24
+ export function estimateTokensFromText(
25
+ text: string,
26
+ charsPerToken: number,
27
+ ): number {
28
+ return estimateTokensFromCharCount(text.length, charsPerToken);
29
+ }
30
+
31
+ /**
32
+ * Byte-size estimation is used for pointer-only operations where file content
33
+ * is intentionally not loaded.
34
+ */
35
+ export function estimateTokensFromBytes(
36
+ byteCount: number,
37
+ charsPerToken: number,
38
+ ): number {
39
+ return estimateTokensFromCharCount(byteCount, charsPerToken);
40
+ }
41
+
42
+ export function estimateSavings(
43
+ fullCatalogEstimatedTokens: number,
44
+ usedEstimatedTokens: number,
45
+ ): { estimatedSavingsTokens: number; estimatedSavingsPercent: number } {
46
+ const full = Math.max(0, Math.ceil(fullCatalogEstimatedTokens));
47
+ const used = Math.max(0, Math.ceil(usedEstimatedTokens));
48
+ if (full <= 0) {
49
+ return {
50
+ estimatedSavingsTokens: 0,
51
+ estimatedSavingsPercent: 0,
52
+ };
53
+ }
54
+
55
+ const estimatedSavingsTokens = Math.max(0, full - used);
56
+ const estimatedSavingsPercent = Number(
57
+ ((estimatedSavingsTokens / full) * 100).toFixed(2),
58
+ );
59
+ return {
60
+ estimatedSavingsTokens,
61
+ estimatedSavingsPercent,
62
+ };
63
+ }
64
+
65
+ export interface SkillToolMetrics {
66
+ estimatorVersion: string;
67
+ charsPerToken: number;
68
+ fullCatalogEstimatedTokens: number;
69
+ responseEstimatedTokens: number;
70
+ selectedSkillsEstimatedTokens: number | null;
71
+ loadedSkillEstimatedTokens: number | null;
72
+ estimatedSavingsVsFullCatalog: number;
73
+ estimatedSavingsVsFullCatalogPercent: number;
74
+ estimated: true;
75
+ }
76
+
77
+ export function buildSkillToolMetrics({
78
+ charsPerToken,
79
+ fullCatalogEstimatedTokens,
80
+ responseEstimatedTokens,
81
+ selectedSkillsEstimatedTokens = null,
82
+ loadedSkillEstimatedTokens = null,
83
+ }: {
84
+ charsPerToken: number;
85
+ fullCatalogEstimatedTokens: number;
86
+ responseEstimatedTokens: number;
87
+ selectedSkillsEstimatedTokens?: number | null;
88
+ loadedSkillEstimatedTokens?: number | null;
89
+ }): SkillToolMetrics {
90
+ const usedEstimatedTokens =
91
+ loadedSkillEstimatedTokens ??
92
+ selectedSkillsEstimatedTokens ??
93
+ responseEstimatedTokens;
94
+ const savings = estimateSavings(fullCatalogEstimatedTokens, usedEstimatedTokens);
95
+
96
+ return {
97
+ estimatorVersion: TOKEN_ESTIMATOR_VERSION,
98
+ charsPerToken: normalizeCharsPerToken(charsPerToken),
99
+ fullCatalogEstimatedTokens: Math.max(0, fullCatalogEstimatedTokens),
100
+ responseEstimatedTokens: Math.max(0, responseEstimatedTokens),
101
+ selectedSkillsEstimatedTokens:
102
+ selectedSkillsEstimatedTokens === null
103
+ ? null
104
+ : Math.max(0, selectedSkillsEstimatedTokens),
105
+ loadedSkillEstimatedTokens:
106
+ loadedSkillEstimatedTokens === null
107
+ ? null
108
+ : Math.max(0, loadedSkillEstimatedTokens),
109
+ estimatedSavingsVsFullCatalog: savings.estimatedSavingsTokens,
110
+ estimatedSavingsVsFullCatalogPercent: savings.estimatedSavingsPercent,
111
+ estimated: true,
112
+ };
113
+ }
114
+
@@ -33,6 +33,13 @@ export {
33
33
  handleSkillGet,
34
34
  } from "./skillGet.js";
35
35
 
36
+ export {
37
+ skillBudgetReportName,
38
+ skillBudgetReportDescription,
39
+ skillBudgetReportSchema,
40
+ handleSkillBudgetReport,
41
+ } from "./skillBudgetReport.js";
42
+
36
43
  export {
37
44
  postmanGetModeName,
38
45
  postmanGetModeDescription,
@@ -8,6 +8,11 @@ import { z } from "zod";
8
8
  import type { VaultManifest } from "../vault/types.js";
9
9
  import { enrichWithDescriptions } from "../vault/manifest.js";
10
10
  import { notFound } from "../utils/errors.js";
11
+ import {
12
+ buildSkillToolMetrics,
13
+ estimateTokensFromBytes,
14
+ estimateTokensFromText,
15
+ } from "../telemetry/tokenBudget.js";
11
16
 
12
17
  export const skillBrowseCategoryName = "skill_browse_category";
13
18
 
@@ -24,6 +29,7 @@ export async function handleSkillBrowseCategory(
24
29
  args: z.infer<typeof skillBrowseCategorySchema>,
25
30
  manifest: VaultManifest,
26
31
  summaryMaxLength: number,
32
+ charsPerToken: number,
27
33
  ) {
28
34
  const { category } = args;
29
35
 
@@ -38,17 +44,28 @@ export async function handleSkillBrowseCategory(
38
44
  id: s.id,
39
45
  description: s.description ?? "(no description)",
40
46
  }));
47
+ const payload = { category, skills, count: skills.length };
48
+ const text = JSON.stringify(payload, null, 2);
49
+ const selectedSkillsEstimatedTokens = matching.reduce(
50
+ (sum, skill) => sum + estimateTokensFromBytes(skill.fileBytes, charsPerToken),
51
+ 0,
52
+ );
53
+ const metrics = buildSkillToolMetrics({
54
+ charsPerToken,
55
+ fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
56
+ responseEstimatedTokens: estimateTokensFromText(text, charsPerToken),
57
+ selectedSkillsEstimatedTokens,
58
+ });
41
59
 
42
60
  return {
43
61
  content: [
44
62
  {
45
63
  type: "text" as const,
46
- text: JSON.stringify(
47
- { category, skills, count: skills.length },
48
- null,
49
- 2,
50
- ),
64
+ text,
51
65
  },
52
66
  ],
67
+ structuredContent: {
68
+ metrics,
69
+ },
53
70
  };
54
71
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Cubis Foundry MCP Server – skill_budget_report tool.
3
+ *
4
+ * Aggregates selected/loaded skill IDs into a deterministic context budget
5
+ * summary using estimated token counts.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import type { VaultManifest } from "../vault/types.js";
10
+ import {
11
+ TOKEN_ESTIMATOR_VERSION,
12
+ estimateSavings,
13
+ estimateTokensFromBytes,
14
+ } from "../telemetry/tokenBudget.js";
15
+
16
+ export const skillBudgetReportName = "skill_budget_report";
17
+
18
+ export const skillBudgetReportDescription =
19
+ "Report estimated context/token budget for selected and loaded skills compared to the full skill catalog.";
20
+
21
+ export const skillBudgetReportSchema = z.object({
22
+ selectedSkillIds: z
23
+ .array(z.string())
24
+ .default([])
25
+ .describe("Skill IDs selected after search/browse."),
26
+ loadedSkillIds: z
27
+ .array(z.string())
28
+ .default([])
29
+ .describe("Skill IDs loaded via skill_get."),
30
+ });
31
+
32
+ function uniqueStrings(values: string[]): string[] {
33
+ return [...new Set(values.map((value) => String(value)))];
34
+ }
35
+
36
+ export function handleSkillBudgetReport(
37
+ args: z.infer<typeof skillBudgetReportSchema>,
38
+ manifest: VaultManifest,
39
+ charsPerToken: number,
40
+ ) {
41
+ const selectedSkillIds = uniqueStrings(args.selectedSkillIds ?? []);
42
+ const loadedSkillIds = uniqueStrings(args.loadedSkillIds ?? []);
43
+ const skillById = new Map(manifest.skills.map((skill) => [skill.id, skill]));
44
+
45
+ const selectedSkills = selectedSkillIds
46
+ .map((id) => {
47
+ const skill = skillById.get(id);
48
+ if (!skill) return null;
49
+ return {
50
+ id: skill.id,
51
+ category: skill.category,
52
+ estimatedTokens: estimateTokensFromBytes(skill.fileBytes, charsPerToken),
53
+ };
54
+ })
55
+ .filter((item): item is NonNullable<typeof item> => Boolean(item));
56
+
57
+ const loadedSkills = loadedSkillIds
58
+ .map((id) => {
59
+ const skill = skillById.get(id);
60
+ if (!skill) return null;
61
+ return {
62
+ id: skill.id,
63
+ category: skill.category,
64
+ estimatedTokens: estimateTokensFromBytes(skill.fileBytes, charsPerToken),
65
+ };
66
+ })
67
+ .filter((item): item is NonNullable<typeof item> => Boolean(item));
68
+
69
+ const unknownSelectedSkillIds = selectedSkillIds.filter(
70
+ (id) => !skillById.has(id),
71
+ );
72
+ const unknownLoadedSkillIds = loadedSkillIds.filter((id) => !skillById.has(id));
73
+
74
+ const selectedSkillsEstimatedTokens = selectedSkills.reduce(
75
+ (sum, skill) => sum + skill.estimatedTokens,
76
+ 0,
77
+ );
78
+ const loadedSkillsEstimatedTokens = loadedSkills.reduce(
79
+ (sum, skill) => sum + skill.estimatedTokens,
80
+ 0,
81
+ );
82
+
83
+ const usedEstimatedTokens =
84
+ loadedSkills.length > 0
85
+ ? loadedSkillsEstimatedTokens
86
+ : selectedSkillsEstimatedTokens;
87
+ const savings = estimateSavings(
88
+ manifest.fullCatalogEstimatedTokens,
89
+ usedEstimatedTokens,
90
+ );
91
+
92
+ const selectedIdSet = new Set(selectedSkills.map((skill) => skill.id));
93
+ const loadedIdSet = new Set(loadedSkills.map((skill) => skill.id));
94
+ const skippedSkills = manifest.skills
95
+ .filter((skill) => !selectedIdSet.has(skill.id) && !loadedIdSet.has(skill.id))
96
+ .map((skill) => skill.id)
97
+ .sort((a, b) => a.localeCompare(b));
98
+
99
+ const payload = {
100
+ skillLog: {
101
+ selectedSkills,
102
+ loadedSkills,
103
+ skippedSkills,
104
+ unknownSelectedSkillIds,
105
+ unknownLoadedSkillIds,
106
+ },
107
+ contextBudget: {
108
+ estimatorVersion: TOKEN_ESTIMATOR_VERSION,
109
+ charsPerToken,
110
+ fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
111
+ selectedSkillsEstimatedTokens,
112
+ loadedSkillsEstimatedTokens,
113
+ estimatedSavingsTokens: savings.estimatedSavingsTokens,
114
+ estimatedSavingsPercent: savings.estimatedSavingsPercent,
115
+ estimated: true,
116
+ },
117
+ };
118
+
119
+ return {
120
+ content: [
121
+ {
122
+ type: "text" as const,
123
+ text: JSON.stringify(payload, null, 2),
124
+ },
125
+ ],
126
+ structuredContent: payload,
127
+ };
128
+ }
@@ -9,6 +9,10 @@ import { z } from "zod";
9
9
  import type { VaultManifest } from "../vault/types.js";
10
10
  import { readFullSkillContent } from "../vault/manifest.js";
11
11
  import { notFound } from "../utils/errors.js";
12
+ import {
13
+ buildSkillToolMetrics,
14
+ estimateTokensFromText,
15
+ } from "../telemetry/tokenBudget.js";
12
16
 
13
17
  export const skillGetName = "skill_get";
14
18
 
@@ -22,6 +26,7 @@ export const skillGetSchema = z.object({
22
26
  export async function handleSkillGet(
23
27
  args: z.infer<typeof skillGetSchema>,
24
28
  manifest: VaultManifest,
29
+ charsPerToken: number,
25
30
  ) {
26
31
  const { id } = args;
27
32
 
@@ -31,6 +36,16 @@ export async function handleSkillGet(
31
36
  }
32
37
 
33
38
  const content = await readFullSkillContent(skill.path);
39
+ const loadedSkillEstimatedTokens = estimateTokensFromText(
40
+ content,
41
+ charsPerToken,
42
+ );
43
+ const metrics = buildSkillToolMetrics({
44
+ charsPerToken,
45
+ fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
46
+ responseEstimatedTokens: loadedSkillEstimatedTokens,
47
+ loadedSkillEstimatedTokens,
48
+ });
34
49
 
35
50
  return {
36
51
  content: [
@@ -39,5 +54,8 @@ export async function handleSkillGet(
39
54
  text: content,
40
55
  },
41
56
  ],
57
+ structuredContent: {
58
+ metrics,
59
+ },
42
60
  };
43
61
  }
@@ -6,6 +6,10 @@
6
6
 
7
7
  import { z } from "zod";
8
8
  import type { VaultManifest } from "../vault/types.js";
9
+ import {
10
+ buildSkillToolMetrics,
11
+ estimateTokensFromText,
12
+ } from "../telemetry/tokenBudget.js";
9
13
 
10
14
  export const skillListCategoriesName = "skill_list_categories";
11
15
 
@@ -14,7 +18,10 @@ export const skillListCategoriesDescription =
14
18
 
15
19
  export const skillListCategoriesSchema = z.object({});
16
20
 
17
- export function handleSkillListCategories(manifest: VaultManifest) {
21
+ export function handleSkillListCategories(
22
+ manifest: VaultManifest,
23
+ charsPerToken: number,
24
+ ) {
18
25
  const categoryCounts: Record<string, number> = {};
19
26
  for (const skill of manifest.skills) {
20
27
  categoryCounts[skill.category] = (categoryCounts[skill.category] ?? 0) + 1;
@@ -24,17 +31,23 @@ export function handleSkillListCategories(manifest: VaultManifest) {
24
31
  category: cat,
25
32
  skillCount: categoryCounts[cat] ?? 0,
26
33
  }));
34
+ const payload = { categories, totalSkills: manifest.skills.length };
35
+ const text = JSON.stringify(payload, null, 2);
36
+ const metrics = buildSkillToolMetrics({
37
+ charsPerToken,
38
+ fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
39
+ responseEstimatedTokens: estimateTokensFromText(text, charsPerToken),
40
+ });
27
41
 
28
42
  return {
29
43
  content: [
30
44
  {
31
45
  type: "text" as const,
32
- text: JSON.stringify(
33
- { categories, totalSkills: manifest.skills.length },
34
- null,
35
- 2,
36
- ),
46
+ text,
37
47
  },
38
48
  ],
49
+ structuredContent: {
50
+ metrics,
51
+ },
39
52
  };
40
53
  }