@askthew/mcp-plugin 0.2.8 → 0.4.0
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/README.md +49 -11
- package/dist/cli.js +148 -10
- package/dist/index.d.ts +23 -11
- package/dist/index.js +731 -95
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +349 -0
- package/dist/install.d.ts +16 -1
- package/dist/install.js +16 -8
- package/dist/install.test.d.ts +1 -0
- package/dist/install.test.js +237 -0
- package/dist/lib/auth-magic-link.d.ts +22 -0
- package/dist/lib/auth-magic-link.js +43 -0
- package/dist/lib/free-tier-policy.d.ts +19 -0
- package/dist/lib/free-tier-policy.js +53 -0
- package/dist/lib/local-store.d.ts +99 -0
- package/dist/lib/local-store.js +423 -0
- package/dist/lib/loopback-auth.d.ts +8 -0
- package/dist/lib/loopback-auth.js +30 -0
- package/dist/lib/paths.d.ts +7 -0
- package/dist/lib/paths.js +44 -0
- package/dist/lib/telemetry.d.ts +25 -0
- package/dist/lib/telemetry.js +133 -0
- package/dist/lib/tip-engine.d.ts +18 -0
- package/dist/lib/tip-engine.js +237 -0
- package/dist/lib/upgrade-nudge.d.ts +19 -0
- package/dist/lib/upgrade-nudge.js +30 -0
- package/dist/lib/upgrade-sync.d.ts +38 -0
- package/dist/lib/upgrade-sync.js +60 -0
- package/dist/local-store.test.d.ts +1 -0
- package/dist/local-store.test.js +37 -0
- package/dist/scope.d.ts +0 -1
- package/dist/scope.js +0 -6
- package/dist/scope.test.d.ts +1 -0
- package/dist/scope.test.js +32 -0
- package/dist/tip-engine.test.d.ts +1 -0
- package/dist/tip-engine.test.js +51 -0
- package/package.json +7 -10
package/dist/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
3
|
+
import { resolvePluginScope } from "./scope.js";
|
|
4
|
+
import { resolveMcpMode } from "./lib/free-tier-policy.js";
|
|
5
|
+
import { LocalStore } from "./lib/local-store.js";
|
|
6
|
+
import { analyzeLocalPatterns } from "./lib/tip-engine.js";
|
|
7
|
+
import { buildTelemetryPayload, flushTelemetryOutbox } from "./lib/telemetry.js";
|
|
8
|
+
import { paidDescription, paidFeatureNudge, toolJson } from "./lib/upgrade-nudge.js";
|
|
6
9
|
const evidenceRoleSchema = z.enum(["user", "assistant", "system"]);
|
|
7
10
|
const sessionSignalKindSchema = z.enum([
|
|
8
11
|
"setup_complete",
|
|
@@ -41,85 +44,228 @@ export const provenanceSignalSchema = z.object({
|
|
|
41
44
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
42
45
|
});
|
|
43
46
|
const REDACTION_PATTERNS = [
|
|
44
|
-
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
45
|
-
/\b
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
{ name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
48
|
+
{ name: "aws_secret_key", pattern: /\b[A-Za-z0-9+/]{40}\b/g },
|
|
49
|
+
{ name: "gcp_api_key", pattern: /\bAIza[0-9A-Za-z\-_]{35}\b/g },
|
|
50
|
+
{ name: "azure_storage_key", pattern: /[A-Za-z0-9+/]{86}==/g },
|
|
51
|
+
{ name: "stripe_key", pattern: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
52
|
+
{ name: "sendgrid_key", pattern: /\bSG\.[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{22,}\b/g },
|
|
53
|
+
{ name: "twilio_key", pattern: /\bSK[0-9a-fA-F]{32}\b/g },
|
|
54
|
+
{ name: "slack_token", pattern: /\bxox[baprs]-[0-9A-Za-z\-]{10,}\b/g },
|
|
55
|
+
{ name: "slack_webhook", pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g },
|
|
56
|
+
{ name: "openai_key", pattern: /\bsk-[A-Za-z0-9]{20,}\b/g },
|
|
57
|
+
{ name: "anthropic_key", pattern: /\bsk-ant-[A-Za-z0-9\-_]{32,}\b/g },
|
|
58
|
+
{ name: "github_pat_classic", pattern: /\bghp_[A-Za-z0-9]{36}\b/g },
|
|
59
|
+
{ name: "github_pat_fine", pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
|
|
60
|
+
{ name: "github_oauth", pattern: /\bgho_[A-Za-z0-9]{36}\b/g },
|
|
61
|
+
{ name: "github_app_token", pattern: /\bghs_[A-Za-z0-9]{36}\b/g },
|
|
62
|
+
{ name: "gitlab_pat", pattern: /\bglpat-[A-Za-z0-9\-_]{20,}\b/g },
|
|
63
|
+
{ name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
64
|
+
{ name: "pem_private_key", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
|
|
65
|
+
{ name: "db_dsn_with_creds", pattern: /(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis):\/\/[^:]+:[^@\s]+@[^\s"'`]+/gi },
|
|
66
|
+
{ name: "atw_token", pattern: /\b(?:atw|askthew)_[a-z]+_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_[0-9a-f]{16,}\b/gi },
|
|
67
|
+
{ name: "uuid_token", pattern: /\b\w{2,20}_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:_[A-Za-z0-9]{8,})?\b/g },
|
|
68
|
+
{ name: "env_var_assignment", pattern: /(?:^|[\s;|&])(?:export\s+)?[A-Z][A-Z0-9_]{4,}(?:_KEY|_TOKEN|_SECRET|_PASSWORD|_PASS|_CREDENTIAL|_API_KEY|_PRIVATE|_AUTH)\s*[=:]\s*["']?[A-Za-z0-9+/=._~-]{8,}["']?/gm },
|
|
69
|
+
{ name: "email", pattern: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g },
|
|
70
|
+
{ name: "us_phone", pattern: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g },
|
|
71
|
+
{ name: "ssn", pattern: /\b(?!000|666|9\d{2})\d{3}[-\s](?!00)\d{2}[-\s](?!0{4})\d{4}\b/g },
|
|
72
|
+
{ name: "credit_card", pattern: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b/g },
|
|
48
73
|
];
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
const SENSITIVE_QUERY_PARAMS = new Set([
|
|
75
|
+
"token",
|
|
76
|
+
"key",
|
|
77
|
+
"api_key",
|
|
78
|
+
"apikey",
|
|
79
|
+
"secret",
|
|
80
|
+
"password",
|
|
81
|
+
"passwd",
|
|
82
|
+
"pass",
|
|
83
|
+
"auth",
|
|
84
|
+
"access_token",
|
|
85
|
+
"refresh_token",
|
|
86
|
+
"id_token",
|
|
87
|
+
"client_secret",
|
|
88
|
+
"authorization",
|
|
89
|
+
"credential",
|
|
90
|
+
"sig",
|
|
91
|
+
"signature",
|
|
92
|
+
]);
|
|
93
|
+
const SENSITIVE_FLAG_NAMES = new Set([
|
|
94
|
+
"token",
|
|
95
|
+
"password",
|
|
96
|
+
"passwd",
|
|
97
|
+
"pass",
|
|
98
|
+
"secret",
|
|
99
|
+
"key",
|
|
100
|
+
"api-key",
|
|
101
|
+
"apikey",
|
|
102
|
+
"api_key",
|
|
103
|
+
"auth",
|
|
104
|
+
"access-token",
|
|
105
|
+
"private-key",
|
|
106
|
+
"credential",
|
|
107
|
+
"credentials",
|
|
108
|
+
"authorization",
|
|
109
|
+
"client-secret",
|
|
110
|
+
"client_secret",
|
|
111
|
+
]);
|
|
112
|
+
const SENSITIVE_PATH_SEGMENTS = new Set([
|
|
113
|
+
".env",
|
|
114
|
+
".ssh",
|
|
115
|
+
".aws",
|
|
116
|
+
".gcp",
|
|
117
|
+
".azure",
|
|
118
|
+
".gnupg",
|
|
119
|
+
".pgp",
|
|
120
|
+
"secrets",
|
|
121
|
+
"secret",
|
|
122
|
+
"credentials",
|
|
123
|
+
"credential",
|
|
124
|
+
"private",
|
|
125
|
+
"keys",
|
|
126
|
+
"certs",
|
|
127
|
+
"certificates",
|
|
128
|
+
"vault",
|
|
129
|
+
".netrc",
|
|
130
|
+
".npmrc",
|
|
131
|
+
".pypirc",
|
|
132
|
+
".docker",
|
|
133
|
+
"kubeconfig",
|
|
134
|
+
".kube",
|
|
135
|
+
]);
|
|
136
|
+
const ENTROPY_THRESHOLD = 4.5;
|
|
137
|
+
const ENTROPY_TOKEN_PATTERN = /[A-Za-z0-9+/=_\-]{20,}/g;
|
|
138
|
+
function sanitizeUrl(raw) {
|
|
139
|
+
try {
|
|
140
|
+
const url = new URL(raw);
|
|
141
|
+
if (url.username || url.password) {
|
|
142
|
+
url.username = "[REDACTED]";
|
|
143
|
+
url.password = "[REDACTED]";
|
|
144
|
+
}
|
|
145
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
146
|
+
if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) {
|
|
147
|
+
url.searchParams.set(key, "[REDACTED]");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return url.toString().replace(/%5BREDACTED%5D/gi, "[REDACTED]");
|
|
58
151
|
}
|
|
59
|
-
|
|
60
|
-
return
|
|
152
|
+
catch {
|
|
153
|
+
return raw;
|
|
61
154
|
}
|
|
62
|
-
return value;
|
|
63
155
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
156
|
+
function sanitizeUrls(text) {
|
|
157
|
+
return text.replace(/https?:\/\/[^\s"'`<>)\\]*/g, (match) => sanitizeUrl(match));
|
|
158
|
+
}
|
|
159
|
+
function scrubCommandFlags(command) {
|
|
160
|
+
let result = command.replace(/(-{1,2})([\w-]+)([ =])(["'])([^"']+)\4/g, (match, dashes, flagName, separator, quote) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
|
|
161
|
+
? `${dashes}${flagName}${separator}${quote}[REDACTED]${quote}`
|
|
162
|
+
: match);
|
|
163
|
+
result = result.replace(/(-{1,2})([\w-]+)([ =])([^\s"'`]+)/g, (match, dashes, flagName, separator) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
|
|
164
|
+
? `${dashes}${flagName}${separator}[REDACTED]`
|
|
165
|
+
: match);
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
function shannonEntropy(value) {
|
|
169
|
+
const freq = new Map();
|
|
170
|
+
for (const char of value) {
|
|
171
|
+
freq.set(char, (freq.get(char) ?? 0) + 1);
|
|
76
172
|
}
|
|
77
|
-
|
|
78
|
-
|
|
173
|
+
let entropy = 0;
|
|
174
|
+
for (const count of freq.values()) {
|
|
175
|
+
const probability = count / value.length;
|
|
176
|
+
entropy -= probability * Math.log2(probability);
|
|
79
177
|
}
|
|
80
|
-
|
|
81
|
-
|
|
178
|
+
return entropy;
|
|
179
|
+
}
|
|
180
|
+
function looksLikeSecret(token) {
|
|
181
|
+
const hasDigit = /\d/.test(token);
|
|
182
|
+
const hasUpper = /[A-Z]/.test(token);
|
|
183
|
+
const hasLower = /[a-z]/.test(token);
|
|
184
|
+
const hasSpecial = /[+/=_\-]/.test(token);
|
|
185
|
+
return hasDigit && hasLower && (hasUpper || hasSpecial);
|
|
186
|
+
}
|
|
187
|
+
function redactHighEntropyTokens(text) {
|
|
188
|
+
return text.replace(ENTROPY_TOKEN_PATTERN, (match) => {
|
|
189
|
+
if (match.includes("/") && match.length < 60) {
|
|
190
|
+
return match;
|
|
191
|
+
}
|
|
192
|
+
if (!looksLikeSecret(match)) {
|
|
193
|
+
return match;
|
|
194
|
+
}
|
|
195
|
+
return shannonEntropy(match) >= ENTROPY_THRESHOLD ? "[REDACTED]" : match;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function redactTargetedPatterns(text) {
|
|
199
|
+
return REDACTION_PATTERNS.reduce((accumulator, entry) => {
|
|
200
|
+
entry.pattern.lastIndex = 0;
|
|
201
|
+
return accumulator.replace(entry.pattern, "[REDACTED]");
|
|
202
|
+
}, text);
|
|
203
|
+
}
|
|
204
|
+
function redactRawSignalText(text) {
|
|
205
|
+
return redactTargetedPatterns(sanitizeUrls(text));
|
|
206
|
+
}
|
|
207
|
+
function redactOperationalContext(text) {
|
|
208
|
+
return redactHighEntropyTokens(scrubCommandFlags(redactRawSignalText(text)));
|
|
209
|
+
}
|
|
210
|
+
function sanitizeFilePath(filePath) {
|
|
211
|
+
let result = filePath.replace(/^(?:[A-Za-z]:\\|\/(?:Users|home|root|var|etc)\/[^/]+\/)/, "~/");
|
|
212
|
+
result = redactRawSignalText(result);
|
|
213
|
+
const segments = result.split(/[/\\]/);
|
|
214
|
+
if (segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()))) {
|
|
215
|
+
const basename = segments[segments.length - 1] ?? result;
|
|
216
|
+
return `[SENSITIVE_PATH]/${basename}`;
|
|
82
217
|
}
|
|
83
|
-
|
|
84
|
-
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
function redactMetadata(value, key) {
|
|
221
|
+
if (typeof value === "string") {
|
|
222
|
+
const redacted = redactOperationalContext(value);
|
|
223
|
+
if (key && /(?:^|_)(?:path|root|file|dir|directory)(?:$|_)/i.test(key)) {
|
|
224
|
+
return sanitizeFilePath(redacted);
|
|
225
|
+
}
|
|
226
|
+
return redacted;
|
|
85
227
|
}
|
|
86
|
-
if (
|
|
87
|
-
return
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
return value.map((entry) => redactMetadata(entry));
|
|
88
230
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return "Engineering";
|
|
231
|
+
if (typeof value === "object" && value !== null) {
|
|
232
|
+
return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [entryKey, redactMetadata(entry, entryKey)]));
|
|
92
233
|
}
|
|
93
|
-
return
|
|
234
|
+
return value;
|
|
94
235
|
}
|
|
95
236
|
export function redactProvenanceSignal(input) {
|
|
96
237
|
const parsed = provenanceSignalSchema.parse(input);
|
|
97
238
|
return {
|
|
98
239
|
...parsed,
|
|
240
|
+
decision: redactRawSignalText(parsed.decision),
|
|
241
|
+
rationale: redactRawSignalText(parsed.rationale),
|
|
242
|
+
framework: typeof parsed.framework === "string" ? redactRawSignalText(parsed.framework) : undefined,
|
|
243
|
+
filesAffected: parsed.filesAffected.map((filePath) => sanitizeFilePath(filePath)),
|
|
99
244
|
originatingPrompt: typeof parsed.originatingPrompt === "string"
|
|
100
|
-
?
|
|
245
|
+
? redactOperationalContext(parsed.originatingPrompt)
|
|
101
246
|
: undefined,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
247
|
+
installToken: typeof parsed.installToken === "string"
|
|
248
|
+
? redactOperationalContext(parsed.installToken)
|
|
249
|
+
: undefined,
|
|
250
|
+
metadata: redactMetadata(parsed.metadata),
|
|
106
251
|
};
|
|
107
252
|
}
|
|
108
253
|
export function redactCodingSessionSignal(input) {
|
|
109
254
|
const parsed = codingSessionSignalSchema.parse(input);
|
|
110
255
|
return {
|
|
111
256
|
...parsed,
|
|
112
|
-
summary:
|
|
257
|
+
summary: redactRawSignalText(parsed.summary),
|
|
113
258
|
evidence: parsed.evidence.map((entry) => ({
|
|
114
259
|
...entry,
|
|
115
|
-
excerpt:
|
|
260
|
+
excerpt: redactOperationalContext(entry.excerpt),
|
|
116
261
|
})),
|
|
117
|
-
|
|
262
|
+
filesTouched: parsed.filesTouched.map((filePath) => sanitizeFilePath(filePath)),
|
|
263
|
+
commandsRun: parsed.commandsRun.map((command) => redactOperationalContext(command)),
|
|
118
264
|
metadata: redactMetadata(parsed.metadata),
|
|
119
265
|
};
|
|
120
266
|
}
|
|
121
|
-
function apiBaseUrl() {
|
|
122
|
-
return process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com";
|
|
267
|
+
function apiBaseUrl(override) {
|
|
268
|
+
return override?.trim() || process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com";
|
|
123
269
|
}
|
|
124
270
|
function normalizeClientId(value) {
|
|
125
271
|
return String(value ?? "")
|
|
@@ -132,75 +278,126 @@ function normalizeClientId(value) {
|
|
|
132
278
|
export function normalizeInstallTokenInput(token) {
|
|
133
279
|
return String(token ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
|
|
134
280
|
}
|
|
135
|
-
function
|
|
281
|
+
function routeWithQuery(route, params) {
|
|
282
|
+
const searchParams = new URLSearchParams();
|
|
283
|
+
for (const [key, value] of Object.entries(params)) {
|
|
284
|
+
if (value === undefined || value === null || value === "") {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
searchParams.set(key, String(value));
|
|
288
|
+
}
|
|
289
|
+
const query = searchParams.toString();
|
|
290
|
+
return query ? `${route}?${query}` : route;
|
|
291
|
+
}
|
|
292
|
+
function credentials(overrides) {
|
|
136
293
|
const legacyHostType = process.env.ASKTHEW_HOST_TYPE?.trim();
|
|
137
294
|
const hostType = legacyHostType === "claude_code" || legacyHostType === "codex" || legacyHostType === "cursor"
|
|
138
295
|
? legacyHostType
|
|
139
296
|
: undefined;
|
|
140
|
-
const
|
|
297
|
+
const overrideHostType = overrides?.hostType;
|
|
298
|
+
const clientId = normalizeClientId(overrides?.clientId) ||
|
|
299
|
+
normalizeClientId(process.env.ASKTHEW_CLIENT_ID?.trim()) ||
|
|
300
|
+
overrideHostType ||
|
|
301
|
+
hostType ||
|
|
302
|
+
"mcp_client";
|
|
141
303
|
return {
|
|
142
|
-
installToken: normalizeInstallTokenInput(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
304
|
+
installToken: normalizeInstallTokenInput(overrides?.installToken) ||
|
|
305
|
+
normalizeInstallTokenInput(process.env.ASKTHEW_INSTALL_TOKEN),
|
|
306
|
+
userId: overrides?.userId?.trim() || process.env.ASKTHEW_USER_ID?.trim(),
|
|
307
|
+
apiKey: overrides?.apiKey?.trim() || process.env.ASKTHEW_API_KEY?.trim(),
|
|
308
|
+
serverName: overrides?.serverName?.trim() || process.env.ASKTHEW_SERVER_NAME?.trim(),
|
|
146
309
|
clientId,
|
|
147
|
-
clientLabel: process.env.ASKTHEW_CLIENT_LABEL?.trim(),
|
|
148
|
-
hostType,
|
|
310
|
+
clientLabel: overrides?.clientLabel?.trim() || process.env.ASKTHEW_CLIENT_LABEL?.trim(),
|
|
311
|
+
hostType: overrideHostType || hostType,
|
|
149
312
|
};
|
|
150
313
|
}
|
|
151
|
-
function hasServerIdentity() {
|
|
152
|
-
const { installToken, userId } = credentials();
|
|
314
|
+
function hasServerIdentity(overrides) {
|
|
315
|
+
const { installToken, userId } = credentials(overrides);
|
|
153
316
|
return Boolean(installToken || userId);
|
|
154
317
|
}
|
|
155
|
-
async function postToServer(route, payload) {
|
|
156
|
-
if (!hasServerIdentity()) {
|
|
318
|
+
async function postToServer(route, payload, options, request) {
|
|
319
|
+
if (!hasServerIdentity(options?.credentials)) {
|
|
157
320
|
return null;
|
|
158
321
|
}
|
|
159
|
-
const { installToken, userId, apiKey, clientId, clientLabel, hostType } = credentials();
|
|
160
|
-
const
|
|
161
|
-
|
|
322
|
+
const { installToken, userId, apiKey, clientId, clientLabel, hostType } = credentials(options?.credentials);
|
|
323
|
+
const fetcher = options?.fetchImpl ?? fetch;
|
|
324
|
+
const method = request?.method ?? "POST";
|
|
325
|
+
const bodyPayload = {
|
|
326
|
+
...payload,
|
|
327
|
+
installToken: installToken || undefined,
|
|
328
|
+
userId: userId || undefined,
|
|
329
|
+
clientId,
|
|
330
|
+
clientLabel: clientLabel || undefined,
|
|
331
|
+
hostType: hostType || undefined,
|
|
332
|
+
};
|
|
333
|
+
const response = await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}${route}`, {
|
|
334
|
+
method,
|
|
162
335
|
headers: {
|
|
163
|
-
"Content-Type": "application/json",
|
|
164
|
-
...(
|
|
336
|
+
...(method === "GET" ? {} : { "Content-Type": "application/json" }),
|
|
337
|
+
...(installToken
|
|
338
|
+
? { Authorization: `Bearer ${installToken}` }
|
|
339
|
+
: apiKey
|
|
340
|
+
? { Authorization: `Bearer ${apiKey}` }
|
|
341
|
+
: {}),
|
|
165
342
|
},
|
|
166
|
-
body: JSON.stringify(
|
|
167
|
-
...payload,
|
|
168
|
-
installToken: installToken || undefined,
|
|
169
|
-
userId: userId || undefined,
|
|
170
|
-
clientId,
|
|
171
|
-
clientLabel: clientLabel || undefined,
|
|
172
|
-
hostType: hostType || undefined,
|
|
173
|
-
}),
|
|
343
|
+
...(method === "GET" ? {} : { body: JSON.stringify(bodyPayload) }),
|
|
174
344
|
}).catch(() => null);
|
|
175
|
-
if (!response
|
|
176
|
-
return
|
|
345
|
+
if (!response) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
error: "Ask The W server could not be reached.",
|
|
349
|
+
};
|
|
177
350
|
}
|
|
178
|
-
|
|
351
|
+
const body = await response.json().catch(() => null);
|
|
352
|
+
if (!response.ok) {
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
status: response.status,
|
|
356
|
+
...(body && typeof body === "object" ? body : { error: "Ask The W request failed." }),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return body;
|
|
179
360
|
}
|
|
180
|
-
function runtimeMetadata() {
|
|
361
|
+
function runtimeMetadata(options) {
|
|
181
362
|
const scope = resolvePluginScope(process.cwd());
|
|
182
|
-
const { serverName, clientId, clientLabel } = credentials();
|
|
363
|
+
const { serverName, clientId, clientLabel } = credentials(options?.credentials);
|
|
364
|
+
const extraMetadata = typeof options?.runtimeMetadata === "function"
|
|
365
|
+
? options.runtimeMetadata()
|
|
366
|
+
: (options?.runtimeMetadata ?? {});
|
|
183
367
|
return {
|
|
184
368
|
repository: scope.repoName,
|
|
185
369
|
repo_name: scope.repoName,
|
|
186
|
-
...(scope.repoRoot ? { repo_root: scope.repoRoot } : {}),
|
|
187
|
-
...(scope.appPath ? { app_path: scope.appPath } : {}),
|
|
370
|
+
...(scope.repoRoot ? { repo_root: sanitizeFilePath(scope.repoRoot) } : {}),
|
|
371
|
+
...(scope.appPath ? { app_path: sanitizeFilePath(scope.appPath) } : {}),
|
|
188
372
|
...(scope.serviceName ? { service_name: scope.serviceName } : {}),
|
|
189
373
|
...(serverName ? { server_name: serverName } : {}),
|
|
190
374
|
...(clientId ? { client_id: clientId } : {}),
|
|
191
375
|
...(clientLabel ? { client_label: clientLabel } : {}),
|
|
376
|
+
...extraMetadata,
|
|
192
377
|
};
|
|
193
378
|
}
|
|
194
|
-
|
|
195
|
-
|
|
379
|
+
function startupSessionId(options) {
|
|
380
|
+
const scope = resolvePluginScope(process.cwd());
|
|
381
|
+
const { clientId } = credentials(options?.credentials);
|
|
382
|
+
const scopeKey = [scope.repoName, scope.appPath, scope.serviceName]
|
|
383
|
+
.filter((part) => Boolean(part))
|
|
384
|
+
.join(":") || "workspace";
|
|
385
|
+
return ["mcp-startup", clientId || "mcp_client", scopeKey]
|
|
386
|
+
.join(":")
|
|
387
|
+
.replace(/[^a-zA-Z0-9:_-]+/g, "_")
|
|
388
|
+
.slice(0, 240);
|
|
389
|
+
}
|
|
390
|
+
async function sendStartupHeartbeat(options) {
|
|
391
|
+
if (!hasServerIdentity(options?.credentials)) {
|
|
196
392
|
return;
|
|
197
393
|
}
|
|
198
|
-
const { installToken, apiKey, clientId, clientLabel, hostType, serverName } = credentials();
|
|
394
|
+
const { installToken, apiKey, clientId, clientLabel, hostType, serverName } = credentials(options?.credentials);
|
|
199
395
|
if (!installToken) {
|
|
200
396
|
return;
|
|
201
397
|
}
|
|
202
398
|
const scope = resolvePluginScope(process.cwd());
|
|
203
|
-
|
|
399
|
+
const fetcher = options?.fetchImpl ?? fetch;
|
|
400
|
+
await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}/api/connectors/mcp/heartbeat`, {
|
|
204
401
|
method: "POST",
|
|
205
402
|
headers: {
|
|
206
403
|
"Content-Type": "application/json",
|
|
@@ -219,12 +416,79 @@ async function sendStartupHeartbeat() {
|
|
|
219
416
|
}),
|
|
220
417
|
}).catch(() => null);
|
|
221
418
|
}
|
|
222
|
-
|
|
419
|
+
async function sendStartupSetupSignal(options) {
|
|
420
|
+
const sessionSignal = {
|
|
421
|
+
sessionId: startupSessionId(options),
|
|
422
|
+
sequence: 0,
|
|
423
|
+
kind: "setup_complete",
|
|
424
|
+
summary: "Ask The W MCP server started for this coding-agent session.",
|
|
425
|
+
evidence: [],
|
|
426
|
+
filesTouched: [],
|
|
427
|
+
commandsRun: [],
|
|
428
|
+
metadata: {
|
|
429
|
+
...runtimeMetadata(options),
|
|
430
|
+
automated: true,
|
|
431
|
+
operational: true,
|
|
432
|
+
origin: "mcp_server_startup",
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
await postToServer("/api/ingest/mcp", {
|
|
436
|
+
sessionSignal: redactCodingSessionSignal(sessionSignal),
|
|
437
|
+
}, options);
|
|
438
|
+
}
|
|
439
|
+
async function sendStartupSignals(options) {
|
|
440
|
+
await sendStartupHeartbeat(options).catch(() => null);
|
|
441
|
+
await sendStartupSetupSignal(options).catch(() => null);
|
|
442
|
+
}
|
|
443
|
+
export function createAskTheWMcpServer(options = {}) {
|
|
444
|
+
const resolvedMode = resolveMcpMode();
|
|
445
|
+
const optionInstallToken = normalizeInstallTokenInput(options.credentials?.installToken);
|
|
446
|
+
const mode = optionInstallToken
|
|
447
|
+
? { mode: "paid", installToken: optionInstallToken, reason: "options_install_token" }
|
|
448
|
+
: resolvedMode;
|
|
449
|
+
const localStore = mode.mode === "paid" ? null : LocalStore.open();
|
|
450
|
+
if (localStore) {
|
|
451
|
+
void flushTelemetryOutbox({
|
|
452
|
+
store: localStore,
|
|
453
|
+
credentials: mode.cliCredentials ?? {
|
|
454
|
+
userId: "unauthenticated",
|
|
455
|
+
cliToken: "",
|
|
456
|
+
cliTokenId: "none",
|
|
457
|
+
},
|
|
458
|
+
apiUrl: options.apiBaseUrl,
|
|
459
|
+
fetchImpl: options.fetchImpl,
|
|
460
|
+
}).catch(() => null);
|
|
461
|
+
}
|
|
223
462
|
const server = new McpServer({
|
|
224
|
-
name: "
|
|
225
|
-
version: "
|
|
463
|
+
name: "Ask The W Coding Agent Connector",
|
|
464
|
+
version: "0.4.0",
|
|
226
465
|
});
|
|
227
|
-
|
|
466
|
+
if (options.sendStartupHeartbeat !== false) {
|
|
467
|
+
void sendStartupSignals(options);
|
|
468
|
+
}
|
|
469
|
+
const apiToolResponse = async (route, payload = {}, method = "GET") => {
|
|
470
|
+
const upstream = await postToServer(route, payload, options, { method });
|
|
471
|
+
return {
|
|
472
|
+
content: [
|
|
473
|
+
{
|
|
474
|
+
type: "text",
|
|
475
|
+
text: JSON.stringify(upstream, null, 2),
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
};
|
|
479
|
+
};
|
|
480
|
+
const localResponse = (value) => toolJson(value);
|
|
481
|
+
const requireFreeIdentity = () => {
|
|
482
|
+
if (mode.mode === "unauthenticated") {
|
|
483
|
+
return localResponse({
|
|
484
|
+
ok: false,
|
|
485
|
+
code: "free_tier_login_required",
|
|
486
|
+
message: "Run `npx @askthew/mcp-plugin auth login --email you@example.com` to unlock local capture.",
|
|
487
|
+
supportEmail: "support@askthew.com",
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
};
|
|
228
492
|
server.tool("capture_session_signal", {
|
|
229
493
|
sessionId: z.string().min(1),
|
|
230
494
|
sequence: z.number().int().nonnegative(),
|
|
@@ -243,13 +507,50 @@ export function createAskTheWMcpServer() {
|
|
|
243
507
|
const sessionSignal = redactCodingSessionSignal({
|
|
244
508
|
...payload,
|
|
245
509
|
metadata: {
|
|
246
|
-
...runtimeMetadata(),
|
|
510
|
+
...runtimeMetadata(options),
|
|
247
511
|
...(payload.metadata ?? {}),
|
|
248
512
|
},
|
|
249
513
|
});
|
|
514
|
+
if (mode.mode !== "paid" && localStore) {
|
|
515
|
+
const loginRequired = requireFreeIdentity();
|
|
516
|
+
if (loginRequired) {
|
|
517
|
+
return loginRequired;
|
|
518
|
+
}
|
|
519
|
+
const signal = localStore.insertSignal({
|
|
520
|
+
sessionId: sessionSignal.sessionId,
|
|
521
|
+
sequence: sessionSignal.sequence,
|
|
522
|
+
kind: sessionSignal.kind,
|
|
523
|
+
summary: sessionSignal.summary,
|
|
524
|
+
evidence: sessionSignal.evidence,
|
|
525
|
+
filesTouched: sessionSignal.filesTouched,
|
|
526
|
+
commandsRun: sessionSignal.commandsRun,
|
|
527
|
+
metadata: sessionSignal.metadata,
|
|
528
|
+
});
|
|
529
|
+
if (sessionSignal.kind === "final_summary" && mode.cliCredentials) {
|
|
530
|
+
localStore.enqueueTelemetry(buildTelemetryPayload({
|
|
531
|
+
store: localStore,
|
|
532
|
+
credentials: mode.cliCredentials,
|
|
533
|
+
sessionId: sessionSignal.sessionId,
|
|
534
|
+
}));
|
|
535
|
+
void flushTelemetryOutbox({
|
|
536
|
+
store: localStore,
|
|
537
|
+
credentials: mode.cliCredentials,
|
|
538
|
+
apiUrl: options.apiBaseUrl,
|
|
539
|
+
fetchImpl: options.fetchImpl,
|
|
540
|
+
}).catch(() => null);
|
|
541
|
+
}
|
|
542
|
+
return localResponse({
|
|
543
|
+
ok: true,
|
|
544
|
+
tier: "free",
|
|
545
|
+
signal,
|
|
546
|
+
note: localStore.usingJsonFallback
|
|
547
|
+
? "Captured locally in JSON fallback mode because SQLite was unavailable."
|
|
548
|
+
: "Captured locally in SQLite. Aggregate telemetry only may flush on final_summary unless opted out.",
|
|
549
|
+
});
|
|
550
|
+
}
|
|
250
551
|
const upstream = await postToServer("/api/ingest/mcp", {
|
|
251
552
|
sessionSignal,
|
|
252
|
-
});
|
|
553
|
+
}, options);
|
|
253
554
|
return {
|
|
254
555
|
content: [
|
|
255
556
|
{
|
|
@@ -264,5 +565,340 @@ export function createAskTheWMcpServer() {
|
|
|
264
565
|
],
|
|
265
566
|
};
|
|
266
567
|
});
|
|
568
|
+
server.tool("list_decisions", {
|
|
569
|
+
limit: z.number().int().positive().max(300).optional(),
|
|
570
|
+
cursor: z.string().optional(),
|
|
571
|
+
}, async (payload) => {
|
|
572
|
+
if (mode.mode !== "paid" && localStore) {
|
|
573
|
+
const loginRequired = requireFreeIdentity();
|
|
574
|
+
if (loginRequired)
|
|
575
|
+
return loginRequired;
|
|
576
|
+
return localResponse({
|
|
577
|
+
ok: true,
|
|
578
|
+
tier: "free",
|
|
579
|
+
decisions: localStore.listDecisions({ limit: payload.limit ?? 50 }),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return apiToolResponse(routeWithQuery("/api/decisions", {
|
|
583
|
+
limit: payload.limit,
|
|
584
|
+
cursor: payload.cursor,
|
|
585
|
+
}));
|
|
586
|
+
});
|
|
587
|
+
server.tool("get_decision", {
|
|
588
|
+
id: z.string().min(1),
|
|
589
|
+
}, async (payload) => {
|
|
590
|
+
if (mode.mode !== "paid" && localStore) {
|
|
591
|
+
const loginRequired = requireFreeIdentity();
|
|
592
|
+
if (loginRequired)
|
|
593
|
+
return loginRequired;
|
|
594
|
+
const decision = localStore.getDecision(payload.id);
|
|
595
|
+
return localResponse(decision ? { ok: true, tier: "free", decision } : { ok: false, code: "not_found" });
|
|
596
|
+
}
|
|
597
|
+
return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`);
|
|
598
|
+
});
|
|
599
|
+
server.tool("create_decision", {
|
|
600
|
+
content: z.string().min(1),
|
|
601
|
+
}, async (payload) => {
|
|
602
|
+
if (mode.mode !== "paid" && localStore) {
|
|
603
|
+
const loginRequired = requireFreeIdentity();
|
|
604
|
+
if (loginRequired)
|
|
605
|
+
return loginRequired;
|
|
606
|
+
return localResponse({
|
|
607
|
+
ok: true,
|
|
608
|
+
tier: "free",
|
|
609
|
+
decision: localStore.createDecision({
|
|
610
|
+
rawContent: payload.content,
|
|
611
|
+
sessionId: localStore.mostRecentSessionId(),
|
|
612
|
+
}),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return apiToolResponse("/api/decisions", {
|
|
616
|
+
content: payload.content,
|
|
617
|
+
}, "POST");
|
|
618
|
+
});
|
|
619
|
+
server.tool("update_decision", {
|
|
620
|
+
id: z.string().min(1),
|
|
621
|
+
headline: z.string().min(1).optional(),
|
|
622
|
+
why: z.string().optional(),
|
|
623
|
+
status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
|
|
624
|
+
alignment: z.enum(["aligned", "orthogonal", "conflicts", "ambiguous"]).optional(),
|
|
625
|
+
outcomeId: z.string().min(1).optional(),
|
|
626
|
+
}, async (payload) => {
|
|
627
|
+
if (mode.mode !== "paid" && localStore) {
|
|
628
|
+
const loginRequired = requireFreeIdentity();
|
|
629
|
+
if (loginRequired)
|
|
630
|
+
return loginRequired;
|
|
631
|
+
const decision = localStore.updateDecision(payload.id, {
|
|
632
|
+
...(payload.headline ? { headline: payload.headline } : {}),
|
|
633
|
+
...(payload.why !== undefined ? { why: payload.why } : {}),
|
|
634
|
+
...(payload.status ? { status: payload.status } : {}),
|
|
635
|
+
...(payload.alignment !== undefined ? { alignment: payload.alignment } : {}),
|
|
636
|
+
});
|
|
637
|
+
return localResponse(decision ? { ok: true, tier: "free", decision } : { ok: false, code: "not_found" });
|
|
638
|
+
}
|
|
639
|
+
return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`, {
|
|
640
|
+
headline: payload.headline,
|
|
641
|
+
why: payload.why,
|
|
642
|
+
status: payload.status,
|
|
643
|
+
alignment: payload.alignment,
|
|
644
|
+
outcomeId: payload.outcomeId,
|
|
645
|
+
}, "PATCH");
|
|
646
|
+
});
|
|
647
|
+
server.tool("delete_decision", {
|
|
648
|
+
id: z.string().min(1),
|
|
649
|
+
confirmText: z.string().min(1),
|
|
650
|
+
}, async (payload) => {
|
|
651
|
+
if (mode.mode !== "paid" && localStore) {
|
|
652
|
+
const loginRequired = requireFreeIdentity();
|
|
653
|
+
if (loginRequired)
|
|
654
|
+
return loginRequired;
|
|
655
|
+
if (payload.confirmText !== payload.id) {
|
|
656
|
+
return localResponse({ ok: false, code: "confirmation_required", message: "confirmText must match the decision id." });
|
|
657
|
+
}
|
|
658
|
+
return localResponse({ ok: localStore.deleteDecision(payload.id), tier: "free" });
|
|
659
|
+
}
|
|
660
|
+
return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`, {
|
|
661
|
+
confirmText: payload.confirmText,
|
|
662
|
+
}, "DELETE");
|
|
663
|
+
});
|
|
664
|
+
server.tool("list_outcomes", paidDescription("List outcomes from your workspace.", mode.mode), {
|
|
665
|
+
limit: z.number().int().positive().max(300).optional(),
|
|
666
|
+
}, async (payload) => {
|
|
667
|
+
if (mode.mode === "free")
|
|
668
|
+
return localResponse(paidFeatureNudge("list_outcomes"));
|
|
669
|
+
return apiToolResponse(routeWithQuery("/api/outcomes", {
|
|
670
|
+
limit: payload.limit,
|
|
671
|
+
}));
|
|
672
|
+
});
|
|
673
|
+
server.tool("get_outcome", paidDescription("Get outcome detail from your workspace.", mode.mode), {
|
|
674
|
+
id: z.string().min(1),
|
|
675
|
+
}, async (payload) => mode.mode === "free"
|
|
676
|
+
? localResponse(paidFeatureNudge("get_outcome"))
|
|
677
|
+
: apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`));
|
|
678
|
+
server.tool("list_outcome_signals", paidDescription("List signals linked to an outcome.", mode.mode), {
|
|
679
|
+
id: z.string().min(1),
|
|
680
|
+
}, async (payload) => mode.mode === "free"
|
|
681
|
+
? localResponse(paidFeatureNudge("list_outcome_signals"))
|
|
682
|
+
: apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}/signals`));
|
|
683
|
+
server.tool("create_outcome", paidDescription("Create a new outcome.", mode.mode), {
|
|
684
|
+
name: z.string().min(1),
|
|
685
|
+
summary: z.string().optional(),
|
|
686
|
+
causalHypothesis: z.string().optional(),
|
|
687
|
+
suggestedAction: z.string().optional(),
|
|
688
|
+
}, async (payload) => {
|
|
689
|
+
if (mode.mode === "free")
|
|
690
|
+
return localResponse(paidFeatureNudge("create_outcome"));
|
|
691
|
+
return apiToolResponse("/api/outcomes", {
|
|
692
|
+
name: payload.name,
|
|
693
|
+
summary: payload.summary,
|
|
694
|
+
causalHypothesis: payload.causalHypothesis,
|
|
695
|
+
suggestedAction: payload.suggestedAction,
|
|
696
|
+
}, "POST");
|
|
697
|
+
});
|
|
698
|
+
server.tool("update_outcome", paidDescription("Update an outcome.", mode.mode), {
|
|
699
|
+
id: z.string().min(1),
|
|
700
|
+
name: z.string().min(1).optional(),
|
|
701
|
+
summary: z.string().optional(),
|
|
702
|
+
causalHypothesis: z.string().optional(),
|
|
703
|
+
suggestedAction: z.string().optional(),
|
|
704
|
+
status: z.enum(["active", "achieved", "abandoned", "archived"]).optional(),
|
|
705
|
+
}, async (payload) => {
|
|
706
|
+
if (mode.mode === "free")
|
|
707
|
+
return localResponse(paidFeatureNudge("update_outcome"));
|
|
708
|
+
return apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`, {
|
|
709
|
+
name: payload.name,
|
|
710
|
+
summary: payload.summary,
|
|
711
|
+
causalHypothesis: payload.causalHypothesis,
|
|
712
|
+
suggestedAction: payload.suggestedAction,
|
|
713
|
+
status: payload.status,
|
|
714
|
+
}, "PATCH");
|
|
715
|
+
});
|
|
716
|
+
server.tool("delete_outcome", paidDescription("Delete an outcome.", mode.mode), {
|
|
717
|
+
id: z.string().min(1),
|
|
718
|
+
confirmText: z.string().min(1),
|
|
719
|
+
}, async (payload) => {
|
|
720
|
+
if (mode.mode === "free")
|
|
721
|
+
return localResponse(paidFeatureNudge("delete_outcome"));
|
|
722
|
+
return apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`, {
|
|
723
|
+
confirmText: payload.confirmText,
|
|
724
|
+
}, "DELETE");
|
|
725
|
+
});
|
|
726
|
+
server.tool("get_north_star", paidDescription("Read the workspace north-star metric.", mode.mode), {}, async () => mode.mode === "free"
|
|
727
|
+
? localResponse(paidFeatureNudge("get_north_star"))
|
|
728
|
+
: apiToolResponse("/api/north-star"));
|
|
729
|
+
server.tool("update_north_star", paidDescription("Update the workspace north-star metric.", mode.mode), {
|
|
730
|
+
metric: z.string().min(1),
|
|
731
|
+
current: z.string().min(1),
|
|
732
|
+
target: z.string().min(1),
|
|
733
|
+
reason: z.string().min(1),
|
|
734
|
+
}, async (payload) => {
|
|
735
|
+
if (mode.mode === "free")
|
|
736
|
+
return localResponse(paidFeatureNudge("update_north_star"));
|
|
737
|
+
return apiToolResponse("/api/north-star", {
|
|
738
|
+
metric: payload.metric,
|
|
739
|
+
current: payload.current,
|
|
740
|
+
target: payload.target,
|
|
741
|
+
reason: payload.reason,
|
|
742
|
+
}, "POST");
|
|
743
|
+
});
|
|
744
|
+
server.tool("list_signals", {
|
|
745
|
+
limit: z.number().int().positive().max(300).optional(),
|
|
746
|
+
cursor: z.string().optional(),
|
|
747
|
+
}, async (payload) => {
|
|
748
|
+
if (mode.mode !== "paid" && localStore) {
|
|
749
|
+
const loginRequired = requireFreeIdentity();
|
|
750
|
+
if (loginRequired)
|
|
751
|
+
return loginRequired;
|
|
752
|
+
return localResponse({
|
|
753
|
+
ok: true,
|
|
754
|
+
tier: "free",
|
|
755
|
+
signals: localStore.listSignals({ limit: payload.limit ?? 300 }),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return apiToolResponse(routeWithQuery("/api/signals", {
|
|
759
|
+
limit: payload.limit,
|
|
760
|
+
cursor: payload.cursor,
|
|
761
|
+
}));
|
|
762
|
+
});
|
|
763
|
+
server.tool("get_signal", {
|
|
764
|
+
id: z.string().min(1),
|
|
765
|
+
}, async (payload) => {
|
|
766
|
+
if (mode.mode !== "paid" && localStore) {
|
|
767
|
+
const loginRequired = requireFreeIdentity();
|
|
768
|
+
if (loginRequired)
|
|
769
|
+
return loginRequired;
|
|
770
|
+
const signal = localStore.getSignal(Number(payload.id));
|
|
771
|
+
return localResponse(signal ? { ok: true, tier: "free", signal } : { ok: false, code: "not_found" });
|
|
772
|
+
}
|
|
773
|
+
return apiToolResponse(`/api/signals/${encodeURIComponent(payload.id)}`);
|
|
774
|
+
});
|
|
775
|
+
server.tool("review_decisions", {
|
|
776
|
+
since: z.string().optional(),
|
|
777
|
+
status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
|
|
778
|
+
format: z.enum(["markdown", "json"]).default("markdown"),
|
|
779
|
+
limit: z.number().int().positive().max(300).default(50),
|
|
780
|
+
}, async (payload) => {
|
|
781
|
+
if (mode.mode === "paid") {
|
|
782
|
+
return apiToolResponse(routeWithQuery("/api/decisions", {
|
|
783
|
+
since: payload.since,
|
|
784
|
+
status: payload.status,
|
|
785
|
+
limit: payload.limit,
|
|
786
|
+
}));
|
|
787
|
+
}
|
|
788
|
+
if (!localStore)
|
|
789
|
+
return localResponse({ ok: false, code: "local_store_unavailable" });
|
|
790
|
+
const loginRequired = requireFreeIdentity();
|
|
791
|
+
if (loginRequired)
|
|
792
|
+
return loginRequired;
|
|
793
|
+
const decisions = localStore.listDecisions({
|
|
794
|
+
since: payload.since,
|
|
795
|
+
status: payload.status,
|
|
796
|
+
limit: payload.limit,
|
|
797
|
+
});
|
|
798
|
+
return localResponse({
|
|
799
|
+
ok: true,
|
|
800
|
+
tier: "free",
|
|
801
|
+
format: payload.format,
|
|
802
|
+
rendered: renderDecisionDigest(decisions),
|
|
803
|
+
decisions,
|
|
804
|
+
count: decisions.length,
|
|
805
|
+
copyHint: "Copy this output to back up your decisions - `export_decisions` is a paid feature.",
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
server.tool("review_session", {
|
|
809
|
+
sessionId: z.string().optional(),
|
|
810
|
+
format: z.enum(["markdown", "json"]).default("markdown"),
|
|
811
|
+
}, async (payload) => {
|
|
812
|
+
if (!localStore || mode.mode === "paid") {
|
|
813
|
+
return apiToolResponse(routeWithQuery("/api/signals", { sessionId: payload.sessionId }));
|
|
814
|
+
}
|
|
815
|
+
const loginRequired = requireFreeIdentity();
|
|
816
|
+
if (loginRequired)
|
|
817
|
+
return loginRequired;
|
|
818
|
+
const sessionId = payload.sessionId ?? localStore.mostRecentSessionId();
|
|
819
|
+
const signals = sessionId ? localStore.listSignals({ sessionId, limit: 100000 }) : [];
|
|
820
|
+
const counts = signals.reduce((accumulator, signal) => {
|
|
821
|
+
accumulator[signal.kind] = (accumulator[signal.kind] ?? 0) + 1;
|
|
822
|
+
return accumulator;
|
|
823
|
+
}, {});
|
|
824
|
+
return localResponse({
|
|
825
|
+
ok: true,
|
|
826
|
+
tier: "free",
|
|
827
|
+
sessionId,
|
|
828
|
+
format: payload.format,
|
|
829
|
+
rendered: renderSessionFeed(signals),
|
|
830
|
+
signals,
|
|
831
|
+
counts: {
|
|
832
|
+
totalSignals: signals.length,
|
|
833
|
+
byKind: counts,
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
server.tool("analyze_session", {
|
|
838
|
+
sessionId: z.string().optional(),
|
|
839
|
+
window: z.enum(["session", "day", "week"]).default("session"),
|
|
840
|
+
}, async (payload) => {
|
|
841
|
+
if (!localStore || mode.mode === "paid") {
|
|
842
|
+
return localResponse(paidFeatureNudge("analyze_session"));
|
|
843
|
+
}
|
|
844
|
+
const loginRequired = requireFreeIdentity();
|
|
845
|
+
if (loginRequired)
|
|
846
|
+
return loginRequired;
|
|
847
|
+
const sessionId = payload.sessionId ?? localStore.mostRecentSessionId();
|
|
848
|
+
const signals = sessionId ? localStore.listSignals({ sessionId, limit: 100000 }) : [];
|
|
849
|
+
const decisions = localStore.listDecisions({ limit: 100000 });
|
|
850
|
+
const tips = signals.length < 5 ? [] : analyzeLocalPatterns({ signals, decisions });
|
|
851
|
+
const verification = signals.filter((signal) => signal.kind === "verification_result");
|
|
852
|
+
const verificationPass = verification.filter((signal) => /pass|green|ok|success/i.test(signal.summary)).length;
|
|
853
|
+
return localResponse({
|
|
854
|
+
ok: true,
|
|
855
|
+
tier: "free",
|
|
856
|
+
window: payload.window,
|
|
857
|
+
guidance: tips.length === 0
|
|
858
|
+
? "Capture a few signals first - `analyze_session` needs at least 5 signals to surface patterns."
|
|
859
|
+
: undefined,
|
|
860
|
+
windowSummary: {
|
|
861
|
+
signalCount: signals.length,
|
|
862
|
+
decisionsCreated: decisions.length,
|
|
863
|
+
verificationPassRate: verification.length ? verificationPass / verification.length : 0,
|
|
864
|
+
directionChanges: signals.filter((signal) => signal.kind === "direction_change").length,
|
|
865
|
+
filesTouched: new Set(signals.flatMap((signal) => signal.filesTouched)).size,
|
|
866
|
+
},
|
|
867
|
+
tips,
|
|
868
|
+
paidUpsell: "Zones, health-state, velocity trends, and LLM-synthesized cross-session insights are part of the paid tier. https://askthew.com/pricing",
|
|
869
|
+
supportEmail: "support@askthew.com",
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
server.tool("export_decisions", paidDescription("Export decisions from your workspace.", mode.mode), {
|
|
873
|
+
format: z.enum(["json", "markdown", "jsonl"]).default("json"),
|
|
874
|
+
}, async () => mode.mode === "free"
|
|
875
|
+
? localResponse(paidFeatureNudge("export_decisions"))
|
|
876
|
+
: apiToolResponse("/api/export/timeline"));
|
|
267
877
|
return server;
|
|
268
878
|
}
|
|
879
|
+
function renderDecisionDigest(decisions) {
|
|
880
|
+
if (decisions.length === 0) {
|
|
881
|
+
return "# Decisions\n\nNo local decisions captured yet.";
|
|
882
|
+
}
|
|
883
|
+
return [
|
|
884
|
+
"# Decisions",
|
|
885
|
+
"",
|
|
886
|
+
...decisions.map((decision) => [`## ${decision.headline}`, `- id: ${decision.id}`, `- status: ${decision.status}`, `- created: ${decision.createdAt}`, decision.why ? `- why: ${decision.why}` : "- why: not captured"].join("\n")),
|
|
887
|
+
].join("\n\n");
|
|
888
|
+
}
|
|
889
|
+
function renderSessionFeed(signals) {
|
|
890
|
+
if (signals.length === 0) {
|
|
891
|
+
return "# Session\n\nNo local signals captured yet.";
|
|
892
|
+
}
|
|
893
|
+
return [
|
|
894
|
+
"# Session",
|
|
895
|
+
"",
|
|
896
|
+
...signals.map((signal) => [
|
|
897
|
+
`## ${signal.sequence ?? signal.id}. ${signal.kind}`,
|
|
898
|
+
signal.summary,
|
|
899
|
+
`- captured: ${signal.capturedAt}`,
|
|
900
|
+
signal.filesTouched.length ? `- files: ${signal.filesTouched.join(", ")}` : "- files: none",
|
|
901
|
+
signal.commandsRun.length ? `- commands: ${signal.commandsRun.join(" | ")}` : "- commands: none",
|
|
902
|
+
].join("\n")),
|
|
903
|
+
].join("\n\n");
|
|
904
|
+
}
|