@askthew/mcp-plugin 0.2.8 → 0.4.2

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 (60) hide show
  1. package/README.md +65 -16
  2. package/dist/auth-pending.test.d.ts +1 -0
  3. package/dist/auth-pending.test.js +56 -0
  4. package/dist/cli-actions.test.d.ts +1 -0
  5. package/dist/cli-actions.test.js +71 -0
  6. package/dist/cli.d.ts +9 -0
  7. package/dist/cli.js +412 -18
  8. package/dist/cli.test.d.ts +1 -0
  9. package/dist/cli.test.js +274 -0
  10. package/dist/free-tier-policy.test.d.ts +1 -0
  11. package/dist/free-tier-policy.test.js +57 -0
  12. package/dist/index.d.ts +59 -13
  13. package/dist/index.js +1736 -103
  14. package/dist/index.test.d.ts +1 -0
  15. package/dist/index.test.js +952 -0
  16. package/dist/install.d.ts +56 -1
  17. package/dist/install.js +171 -26
  18. package/dist/install.test.d.ts +1 -0
  19. package/dist/install.test.js +297 -0
  20. package/dist/lib/auth-magic-link.d.ts +22 -0
  21. package/dist/lib/auth-magic-link.js +43 -0
  22. package/dist/lib/auth-pending.d.ts +23 -0
  23. package/dist/lib/auth-pending.js +36 -0
  24. package/dist/lib/cli-actions.d.ts +28 -0
  25. package/dist/lib/cli-actions.js +104 -0
  26. package/dist/lib/free-install-registration.d.ts +27 -0
  27. package/dist/lib/free-install-registration.js +52 -0
  28. package/dist/lib/free-tier-policy.d.ts +23 -0
  29. package/dist/lib/free-tier-policy.js +68 -0
  30. package/dist/lib/local-identity.d.ts +44 -0
  31. package/dist/lib/local-identity.js +81 -0
  32. package/dist/lib/local-store.d.ts +130 -0
  33. package/dist/lib/local-store.js +595 -0
  34. package/dist/lib/loopback-auth.d.ts +8 -0
  35. package/dist/lib/loopback-auth.js +30 -0
  36. package/dist/lib/paths.d.ts +9 -0
  37. package/dist/lib/paths.js +50 -0
  38. package/dist/lib/telemetry.d.ts +25 -0
  39. package/dist/lib/telemetry.js +159 -0
  40. package/dist/lib/timeline-insights.d.ts +23 -0
  41. package/dist/lib/timeline-insights.js +115 -0
  42. package/dist/lib/tip-engine.d.ts +18 -0
  43. package/dist/lib/tip-engine.js +237 -0
  44. package/dist/lib/upgrade-nudge.d.ts +19 -0
  45. package/dist/lib/upgrade-nudge.js +37 -0
  46. package/dist/lib/upgrade-sync.d.ts +38 -0
  47. package/dist/lib/upgrade-sync.js +60 -0
  48. package/dist/local-identity.test.d.ts +1 -0
  49. package/dist/local-identity.test.js +29 -0
  50. package/dist/local-store.test.d.ts +1 -0
  51. package/dist/local-store.test.js +71 -0
  52. package/dist/scope.d.ts +1 -2
  53. package/dist/scope.js +56 -8
  54. package/dist/scope.test.d.ts +1 -0
  55. package/dist/scope.test.js +49 -0
  56. package/dist/timeline-insights.test.d.ts +1 -0
  57. package/dist/timeline-insights.test.js +85 -0
  58. package/dist/tip-engine.test.d.ts +1 -0
  59. package/dist/tip-engine.test.js +51 -0
  60. package/package.json +7 -10
package/dist/index.js CHANGED
@@ -1,9 +1,22 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import * as fs from "node:fs";
4
- import * as path from "node:path";
5
- import { resolveFunctionalAreaFromToml, resolvePluginScope } from "./scope.js";
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 { buildTelemetryPayload, flushTelemetryOutbox } from "./lib/telemetry.js";
7
+ import { ensureLocalIdentity } from "./lib/local-identity.js";
8
+ import { buildLocalTimeline, buildTimelineInsights as buildLocalTimelineInsights, renderTimelineMarkdown as renderLocalTimelineMarkdown } from "./lib/timeline-insights.js";
9
+ import { paidDescription, paidFeatureNudge, toolJson } from "./lib/upgrade-nudge.js";
10
+ import { configPath, readJsonFile } from "./lib/paths.js";
6
11
  const evidenceRoleSchema = z.enum(["user", "assistant", "system"]);
12
+ const evidenceEntrySchema = z.object({
13
+ role: evidenceRoleSchema,
14
+ excerpt: z.string().min(1).max(2000),
15
+ kind: z.enum(["excerpt", "diff", "prompt_diff"]).optional(),
16
+ diff: z.string().max(12000).optional(),
17
+ before: z.string().max(6000).optional(),
18
+ after: z.string().max(6000).optional(),
19
+ });
7
20
  const sessionSignalKindSchema = z.enum([
8
21
  "setup_complete",
9
22
  "session_checkpoint",
@@ -18,10 +31,7 @@ export const codingSessionSignalSchema = z.object({
18
31
  kind: sessionSignalKindSchema,
19
32
  summary: z.string().min(1).max(2000),
20
33
  evidence: z
21
- .array(z.object({
22
- role: evidenceRoleSchema,
23
- excerpt: z.string().min(1).max(500),
24
- }))
34
+ .array(evidenceEntrySchema)
25
35
  .default([]),
26
36
  filesTouched: z.array(z.string().min(1).max(500)).default([]),
27
37
  commandsRun: z.array(z.string().min(1).max(500)).default([]),
@@ -41,85 +51,245 @@ export const provenanceSignalSchema = z.object({
41
51
  metadata: z.record(z.string(), z.unknown()).default({}),
42
52
  });
43
53
  const REDACTION_PATTERNS = [
44
- /\bAKIA[0-9A-Z]{16}\b/g,
45
- /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g,
46
- /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g,
47
- /\b[A-Za-z0-9_-]{32,}\b/g,
54
+ { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
55
+ { name: "aws_secret_key", pattern: /\b[A-Za-z0-9+/]{40}\b/g },
56
+ { name: "gcp_api_key", pattern: /\bAIza[0-9A-Za-z\-_]{35}\b/g },
57
+ { name: "azure_storage_key", pattern: /[A-Za-z0-9+/]{86}==/g },
58
+ { name: "stripe_key", pattern: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
59
+ { name: "sendgrid_key", pattern: /\bSG\.[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{22,}\b/g },
60
+ { name: "twilio_key", pattern: /\bSK[0-9a-fA-F]{32}\b/g },
61
+ { name: "slack_token", pattern: /\bxox[baprs]-[0-9A-Za-z\-]{10,}\b/g },
62
+ { name: "slack_webhook", pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g },
63
+ { name: "openai_env_assignment", pattern: /\bOPENAI_API_KEY\s*[=:]\s*["']?sk-[A-Za-z0-9_-]{20,}["']?/g },
64
+ { name: "openai_key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
65
+ { name: "bearer_token", pattern: /\bBearer\s+[A-Za-z0-9._-]+\b/g },
66
+ { name: "anthropic_key", pattern: /\bsk-ant-[A-Za-z0-9\-_]{32,}\b/g },
67
+ { name: "github_pat_classic", pattern: /\bghp_[A-Za-z0-9]{36}\b/g },
68
+ { name: "github_pat_fine", pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
69
+ { name: "github_oauth", pattern: /\bgho_[A-Za-z0-9]{36}\b/g },
70
+ { name: "github_app_token", pattern: /\bghs_[A-Za-z0-9]{36}\b/g },
71
+ { name: "gitlab_pat", pattern: /\bglpat-[A-Za-z0-9\-_]{20,}\b/g },
72
+ { name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
73
+ { name: "pem_private_key", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
74
+ { name: "db_dsn_with_creds", pattern: /(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis):\/\/[^:]+:[^@\s]+@[^\s"'`]+/gi },
75
+ { 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 },
76
+ { 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 },
77
+ { 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 },
78
+ { name: "email", pattern: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g },
79
+ { name: "us_phone", pattern: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g },
80
+ { name: "ssn", pattern: /\b(?!000|666|9\d{2})\d{3}[-\s](?!00)\d{2}[-\s](?!0{4})\d{4}\b/g },
81
+ { 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
82
  ];
49
- function redactSecrets(text) {
50
- return REDACTION_PATTERNS.reduce((accumulator, pattern) => accumulator.replace(pattern, "[REDACTED]"), text);
83
+ const SENSITIVE_QUERY_PARAMS = new Set([
84
+ "token",
85
+ "key",
86
+ "api_key",
87
+ "apikey",
88
+ "secret",
89
+ "password",
90
+ "passwd",
91
+ "pass",
92
+ "auth",
93
+ "access_token",
94
+ "refresh_token",
95
+ "id_token",
96
+ "client_secret",
97
+ "authorization",
98
+ "credential",
99
+ "sig",
100
+ "signature",
101
+ ]);
102
+ const SENSITIVE_FLAG_NAMES = new Set([
103
+ "token",
104
+ "password",
105
+ "passwd",
106
+ "pass",
107
+ "secret",
108
+ "key",
109
+ "api-key",
110
+ "apikey",
111
+ "api_key",
112
+ "auth",
113
+ "access-token",
114
+ "private-key",
115
+ "credential",
116
+ "credentials",
117
+ "authorization",
118
+ "client-secret",
119
+ "client_secret",
120
+ ]);
121
+ const SENSITIVE_PATH_SEGMENTS = new Set([
122
+ ".env",
123
+ ".ssh",
124
+ ".aws",
125
+ ".gcp",
126
+ ".azure",
127
+ ".gnupg",
128
+ ".pgp",
129
+ "secrets",
130
+ "secret",
131
+ "credentials",
132
+ "credential",
133
+ "private",
134
+ "keys",
135
+ "certs",
136
+ "certificates",
137
+ "vault",
138
+ ".netrc",
139
+ ".npmrc",
140
+ ".pypirc",
141
+ ".docker",
142
+ "kubeconfig",
143
+ ".kube",
144
+ ]);
145
+ const ENTROPY_THRESHOLD = 4.5;
146
+ const ENTROPY_TOKEN_PATTERN = /[A-Za-z0-9+/=_\-]{20,}/g;
147
+ export function loadAskTheWConfig(env = process.env) {
148
+ return readJsonFile(configPath(env)) ?? {};
51
149
  }
52
- function redactMetadata(value) {
53
- if (typeof value === "string") {
54
- return redactSecrets(value);
55
- }
56
- if (Array.isArray(value)) {
57
- return value.map((entry) => redactMetadata(entry));
58
- }
59
- if (typeof value === "object" && value !== null) {
60
- return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactMetadata(entry)]));
61
- }
62
- return value;
150
+ function isRedactionEnabled() {
151
+ return loadAskTheWConfig().redaction?.enabled !== false;
63
152
  }
64
- export function inferFunctionalArea(signal) {
65
- const explicit = process.env.ASKTHEW_FUNCTIONAL_AREA?.trim();
66
- if (explicit) {
67
- return explicit;
153
+ function sanitizeUrl(raw) {
154
+ try {
155
+ const url = new URL(raw);
156
+ if (url.username || url.password) {
157
+ url.username = "[REDACTED]";
158
+ url.password = "[REDACTED]";
159
+ }
160
+ for (const key of Array.from(url.searchParams.keys())) {
161
+ if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) {
162
+ url.searchParams.set(key, "[REDACTED]");
163
+ }
164
+ }
165
+ return url.toString().replace(/%5BREDACTED%5D/gi, "[REDACTED]");
68
166
  }
69
- const configuredArea = resolveFunctionalAreaFromToml(process.cwd());
70
- if (configuredArea) {
71
- return configuredArea;
167
+ catch {
168
+ return raw;
72
169
  }
73
- const files = (signal.filesAffected ?? []).map((file) => file.toLowerCase());
74
- if (files.length > 0 && files.every((file) => file.includes("test"))) {
75
- return "QA";
170
+ }
171
+ function sanitizeUrls(text) {
172
+ return text.replace(/https?:\/\/[^\s"'`<>)\\]*/g, (match) => sanitizeUrl(match));
173
+ }
174
+ function scrubCommandFlags(command) {
175
+ let result = command.replace(/(-{1,2})([\w-]+)([ =])(["'])([^"']+)\4/g, (match, dashes, flagName, separator, quote) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
176
+ ? `${dashes}${flagName}${separator}${quote}[REDACTED]${quote}`
177
+ : match);
178
+ result = result.replace(/(-{1,2})([\w-]+)([ =])([^\s"'`]+)/g, (match, dashes, flagName, separator) => SENSITIVE_FLAG_NAMES.has(String(flagName).toLowerCase())
179
+ ? `${dashes}${flagName}${separator}[REDACTED]`
180
+ : match);
181
+ return result;
182
+ }
183
+ function shannonEntropy(value) {
184
+ const freq = new Map();
185
+ for (const char of value) {
186
+ freq.set(char, (freq.get(char) ?? 0) + 1);
76
187
  }
77
- if (files.length > 0 && files.every((file) => file.includes("marketing") || file.includes("content"))) {
78
- return "Marketing";
188
+ let entropy = 0;
189
+ for (const count of freq.values()) {
190
+ const probability = count / value.length;
191
+ entropy -= probability * Math.log2(probability);
79
192
  }
80
- if (files.length > 0 && files.every((file) => file.includes("design"))) {
81
- return "Design";
193
+ return entropy;
194
+ }
195
+ function looksLikeSecret(token) {
196
+ const hasDigit = /\d/.test(token);
197
+ const hasUpper = /[A-Z]/.test(token);
198
+ const hasLower = /[a-z]/.test(token);
199
+ const hasSpecial = /[+/=_\-]/.test(token);
200
+ return hasDigit && hasLower && (hasUpper || hasSpecial);
201
+ }
202
+ function redactHighEntropyTokens(text) {
203
+ return text.replace(ENTROPY_TOKEN_PATTERN, (match) => {
204
+ if (match.includes("/") && match.length < 60) {
205
+ return match;
206
+ }
207
+ if (!looksLikeSecret(match)) {
208
+ return match;
209
+ }
210
+ return shannonEntropy(match) >= ENTROPY_THRESHOLD ? "[REDACTED]" : match;
211
+ });
212
+ }
213
+ function redactTargetedPatterns(text) {
214
+ return REDACTION_PATTERNS.reduce((accumulator, entry) => {
215
+ entry.pattern.lastIndex = 0;
216
+ return accumulator.replace(entry.pattern, "[REDACTED]");
217
+ }, text);
218
+ }
219
+ function redactRawSignalText(text) {
220
+ return redactTargetedPatterns(sanitizeUrls(text));
221
+ }
222
+ function redactOperationalContext(text) {
223
+ return redactHighEntropyTokens(scrubCommandFlags(redactRawSignalText(text)));
224
+ }
225
+ function sanitizeFilePath(filePath) {
226
+ let result = filePath.replace(/^(?:[A-Za-z]:\\|\/(?:Users|home|root|var|etc)\/[^/]+\/)/, "~/");
227
+ result = redactRawSignalText(result);
228
+ const segments = result.split(/[/\\]/);
229
+ if (segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()))) {
230
+ const basename = segments[segments.length - 1] ?? result;
231
+ return `[SENSITIVE_PATH]/${basename}`;
82
232
  }
83
- if (fs.existsSync(path.resolve(process.cwd(), "figma.config")) || fs.existsSync(path.resolve(process.cwd(), ".sketch"))) {
84
- return "Design";
233
+ return result;
234
+ }
235
+ function redactMetadata(value, key) {
236
+ if (typeof value === "string") {
237
+ const redacted = redactOperationalContext(value);
238
+ if (key && /(?:^|_)(?:path|root|file|dir|directory)(?:$|_)/i.test(key)) {
239
+ return sanitizeFilePath(redacted);
240
+ }
241
+ return redacted;
85
242
  }
86
- if (fs.existsSync(path.resolve(process.cwd(), "campaign.yml"))) {
87
- return "Marketing";
243
+ if (Array.isArray(value)) {
244
+ return value.map((entry) => redactMetadata(entry));
88
245
  }
89
- const repoMarkers = ["package.json", "pyproject.toml", "go.mod", "Cargo.toml"];
90
- if (repoMarkers.some((marker) => fs.existsSync(path.resolve(process.cwd(), marker)))) {
91
- return "Engineering";
246
+ if (typeof value === "object" && value !== null) {
247
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [entryKey, redactMetadata(entry, entryKey)]));
92
248
  }
93
- return "Engineering";
249
+ return value;
94
250
  }
95
251
  export function redactProvenanceSignal(input) {
96
252
  const parsed = provenanceSignalSchema.parse(input);
253
+ if (!isRedactionEnabled()) {
254
+ return parsed;
255
+ }
97
256
  return {
98
257
  ...parsed,
258
+ decision: redactRawSignalText(parsed.decision),
259
+ rationale: redactRawSignalText(parsed.rationale),
260
+ framework: typeof parsed.framework === "string" ? redactRawSignalText(parsed.framework) : undefined,
261
+ filesAffected: parsed.filesAffected.map((filePath) => sanitizeFilePath(filePath)),
99
262
  originatingPrompt: typeof parsed.originatingPrompt === "string"
100
- ? redactSecrets(parsed.originatingPrompt)
263
+ ? redactOperationalContext(parsed.originatingPrompt)
101
264
  : undefined,
102
- metadata: {
103
- ...parsed.metadata,
104
- functional_area: inferFunctionalArea(parsed),
105
- },
265
+ installToken: typeof parsed.installToken === "string"
266
+ ? redactOperationalContext(parsed.installToken)
267
+ : undefined,
268
+ metadata: redactMetadata(parsed.metadata),
106
269
  };
107
270
  }
108
271
  export function redactCodingSessionSignal(input) {
109
272
  const parsed = codingSessionSignalSchema.parse(input);
273
+ if (!isRedactionEnabled()) {
274
+ return parsed;
275
+ }
110
276
  return {
111
277
  ...parsed,
112
- summary: redactSecrets(parsed.summary),
278
+ summary: redactRawSignalText(parsed.summary),
113
279
  evidence: parsed.evidence.map((entry) => ({
114
280
  ...entry,
115
- excerpt: redactSecrets(entry.excerpt),
281
+ excerpt: redactOperationalContext(entry.excerpt),
282
+ diff: typeof entry.diff === "string" ? redactOperationalContext(entry.diff) : undefined,
283
+ before: typeof entry.before === "string" ? redactOperationalContext(entry.before) : undefined,
284
+ after: typeof entry.after === "string" ? redactOperationalContext(entry.after) : undefined,
116
285
  })),
117
- commandsRun: parsed.commandsRun.map((command) => redactSecrets(command)),
286
+ filesTouched: parsed.filesTouched.map((filePath) => sanitizeFilePath(filePath)),
287
+ commandsRun: parsed.commandsRun.map((command) => redactOperationalContext(command)),
118
288
  metadata: redactMetadata(parsed.metadata),
119
289
  };
120
290
  }
121
- function apiBaseUrl() {
122
- return process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com";
291
+ function apiBaseUrl(override) {
292
+ return override?.trim() || process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com";
123
293
  }
124
294
  function normalizeClientId(value) {
125
295
  return String(value ?? "")
@@ -132,75 +302,202 @@ function normalizeClientId(value) {
132
302
  export function normalizeInstallTokenInput(token) {
133
303
  return String(token ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
134
304
  }
135
- function credentials() {
305
+ const echoSchema = z.enum(["summary", "full"]).optional();
306
+ const cursorSchema = z.string().optional();
307
+ const idempotencyKeySchema = z.string().min(1).max(200).optional();
308
+ const maxCharsSchema = z.number().int().positive().max(100000).optional();
309
+ function traceId() {
310
+ return `trace_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
311
+ }
312
+ function structuredError(input) {
313
+ return {
314
+ ok: false,
315
+ code: input.code,
316
+ message: input.message,
317
+ retryable: Boolean(input.retryable),
318
+ hint: input.hint ?? "",
319
+ traceId: input.traceId ?? traceId(),
320
+ ...(typeof input.status === "number" ? { status: input.status } : {}),
321
+ ...(input.extra ?? {}),
322
+ };
323
+ }
324
+ function withResponseShape(route, responseShape = "v2") {
325
+ const [path, query = ""] = route.split("?");
326
+ const searchParams = new URLSearchParams(query);
327
+ searchParams.set("response_shape", responseShape);
328
+ const nextQuery = searchParams.toString();
329
+ return nextQuery ? `${path}?${nextQuery}` : path;
330
+ }
331
+ function routeWithQuery(route, params) {
332
+ const searchParams = new URLSearchParams();
333
+ for (const [key, value] of Object.entries(params)) {
334
+ if (value === undefined || value === null || value === "") {
335
+ continue;
336
+ }
337
+ searchParams.set(key, String(value));
338
+ }
339
+ const query = searchParams.toString();
340
+ return query ? `${route}?${query}` : route;
341
+ }
342
+ function credentials(overrides) {
136
343
  const legacyHostType = process.env.ASKTHEW_HOST_TYPE?.trim();
137
344
  const hostType = legacyHostType === "claude_code" || legacyHostType === "codex" || legacyHostType === "cursor"
138
345
  ? legacyHostType
139
346
  : undefined;
140
- const clientId = normalizeClientId(process.env.ASKTHEW_CLIENT_ID?.trim()) || hostType || "mcp_client";
347
+ const overrideHostType = overrides?.hostType;
348
+ const clientId = normalizeClientId(overrides?.clientId) ||
349
+ normalizeClientId(process.env.ASKTHEW_CLIENT_ID?.trim()) ||
350
+ overrideHostType ||
351
+ hostType ||
352
+ "mcp_client";
141
353
  return {
142
- installToken: normalizeInstallTokenInput(process.env.ASKTHEW_INSTALL_TOKEN),
143
- userId: process.env.ASKTHEW_USER_ID?.trim(),
144
- apiKey: process.env.ASKTHEW_API_KEY?.trim(),
145
- serverName: process.env.ASKTHEW_SERVER_NAME?.trim(),
354
+ installToken: normalizeInstallTokenInput(overrides?.installToken) ||
355
+ normalizeInstallTokenInput(process.env.ASKTHEW_INSTALL_TOKEN),
356
+ userId: overrides?.userId?.trim() || process.env.ASKTHEW_USER_ID?.trim(),
357
+ apiKey: overrides?.apiKey?.trim() || process.env.ASKTHEW_API_KEY?.trim(),
358
+ serverName: overrides?.serverName?.trim() || process.env.ASKTHEW_SERVER_NAME?.trim(),
146
359
  clientId,
147
- clientLabel: process.env.ASKTHEW_CLIENT_LABEL?.trim(),
148
- hostType,
360
+ clientLabel: overrides?.clientLabel?.trim() || process.env.ASKTHEW_CLIENT_LABEL?.trim(),
361
+ hostType: overrideHostType || hostType,
149
362
  };
150
363
  }
151
- function hasServerIdentity() {
152
- const { installToken, userId } = credentials();
364
+ function hasServerIdentity(overrides) {
365
+ const { installToken, userId } = credentials(overrides);
153
366
  return Boolean(installToken || userId);
154
367
  }
155
- async function postToServer(route, payload) {
156
- if (!hasServerIdentity()) {
368
+ async function postToServer(route, payload, options, request) {
369
+ if (!hasServerIdentity(options?.credentials)) {
157
370
  return null;
158
371
  }
159
- const { installToken, userId, apiKey, clientId, clientLabel, hostType } = credentials();
160
- const response = await fetch(`${apiBaseUrl()}${route}`, {
161
- method: "POST",
372
+ const { installToken, userId, apiKey, clientId, clientLabel, hostType } = credentials(options?.credentials);
373
+ const fetcher = options?.fetchImpl ?? fetch;
374
+ const method = request?.method ?? "POST";
375
+ const bodyPayload = {
376
+ ...payload,
377
+ installToken: installToken || undefined,
378
+ userId: userId || undefined,
379
+ clientId,
380
+ clientLabel: clientLabel || undefined,
381
+ hostType: hostType || undefined,
382
+ };
383
+ const response = await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}${route}`, {
384
+ method,
162
385
  headers: {
163
- "Content-Type": "application/json",
164
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
386
+ ...(method === "GET" ? {} : { "Content-Type": "application/json" }),
387
+ ...(request?.idempotencyKey ? { "Idempotency-Key": request.idempotencyKey } : {}),
388
+ ...(installToken
389
+ ? { Authorization: `Bearer ${installToken}` }
390
+ : apiKey
391
+ ? { Authorization: `Bearer ${apiKey}` }
392
+ : {}),
165
393
  },
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
- }),
394
+ ...(method === "GET" ? {} : { body: JSON.stringify(bodyPayload) }),
174
395
  }).catch(() => null);
175
- if (!response || !response.ok) {
176
- return null;
396
+ if (!response) {
397
+ return structuredError({
398
+ code: "network_error",
399
+ message: "Ask The W server could not be reached.",
400
+ retryable: true,
401
+ hint: "Check your network connection or retry the same idempotency key.",
402
+ });
403
+ }
404
+ const body = await response.json().catch(() => null);
405
+ if (!response.ok) {
406
+ if (body && typeof body === "object") {
407
+ const record = body;
408
+ return {
409
+ ...structuredError({
410
+ code: typeof record.code === "string" ? record.code : "http_error",
411
+ message: typeof record.message === "string"
412
+ ? record.message
413
+ : typeof record.error === "string"
414
+ ? record.error
415
+ : `Ask The W request failed with HTTP ${response.status}.`,
416
+ retryable: response.status === 429 || response.status >= 500,
417
+ hint: typeof record.hint === "string"
418
+ ? record.hint
419
+ : response.status >= 500
420
+ ? "Retry with the same Idempotency-Key if this was a write."
421
+ : "Check the tool input and retry.",
422
+ traceId: typeof record.traceId === "string" ? record.traceId : undefined,
423
+ status: response.status,
424
+ }),
425
+ ...record,
426
+ };
427
+ }
428
+ return structuredError({
429
+ code: "http_error",
430
+ message: `Ask The W request failed with HTTP ${response.status}.`,
431
+ retryable: response.status === 429 || response.status >= 500,
432
+ hint: response.status >= 500 ? "Retry with the same Idempotency-Key if this was a write." : "Check the tool input and retry.",
433
+ status: response.status,
434
+ });
177
435
  }
178
- return response.json().catch(() => null);
436
+ return body;
179
437
  }
180
- function runtimeMetadata() {
438
+ function runtimeMetadata(options) {
181
439
  const scope = resolvePluginScope(process.cwd());
182
- const { serverName, clientId, clientLabel } = credentials();
440
+ const { serverName, clientId, clientLabel } = credentials(options?.credentials);
441
+ const extraMetadata = typeof options?.runtimeMetadata === "function"
442
+ ? options.runtimeMetadata()
443
+ : (options?.runtimeMetadata ?? {});
183
444
  return {
184
445
  repository: scope.repoName,
185
446
  repo_name: scope.repoName,
186
- ...(scope.repoRoot ? { repo_root: scope.repoRoot } : {}),
187
- ...(scope.appPath ? { app_path: scope.appPath } : {}),
447
+ scope_key: localScopeKey(),
448
+ ...(scope.repoRoot ? { repo_root: sanitizeFilePath(scope.repoRoot) } : {}),
449
+ ...(scope.appPath ? { app_path: sanitizeFilePath(scope.appPath) } : {}),
188
450
  ...(scope.serviceName ? { service_name: scope.serviceName } : {}),
189
451
  ...(serverName ? { server_name: serverName } : {}),
190
452
  ...(clientId ? { client_id: clientId } : {}),
191
453
  ...(clientLabel ? { client_label: clientLabel } : {}),
454
+ ...extraMetadata,
192
455
  };
193
456
  }
194
- async function sendStartupHeartbeat() {
195
- if (!hasServerIdentity()) {
457
+ function localScopeKey(cwd = process.cwd()) {
458
+ const scope = resolvePluginScope(cwd);
459
+ return [scope.repoRoot || scope.repoName || cwd, scope.appPath ?? "", scope.serviceName ?? ""]
460
+ .filter(Boolean)
461
+ .join("::")
462
+ .replace(/\s+/g, " ")
463
+ .slice(0, 500);
464
+ }
465
+ function startupSessionId(options) {
466
+ const scope = resolvePluginScope(process.cwd());
467
+ const { clientId } = credentials(options?.credentials);
468
+ const scopeKey = [scope.repoName, scope.appPath, scope.serviceName]
469
+ .filter((part) => Boolean(part))
470
+ .join(":") || "workspace";
471
+ return ["mcp-startup", clientId || "mcp_client", scopeKey]
472
+ .join(":")
473
+ .replace(/[^a-zA-Z0-9:_-]+/g, "_")
474
+ .slice(0, 240);
475
+ }
476
+ function loginCommandHint() {
477
+ for (const value of [
478
+ process.env.ASKTHEW_EMAIL,
479
+ process.env.GIT_AUTHOR_EMAIL,
480
+ process.env.GIT_COMMITTER_EMAIL,
481
+ process.env.EMAIL,
482
+ ]) {
483
+ const email = String(value ?? "").trim();
484
+ if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
485
+ return `npx @askthew/mcp-plugin identify --email ${email}`;
486
+ }
487
+ }
488
+ return "npx @askthew/mcp-plugin identify --email <your-email>";
489
+ }
490
+ async function sendStartupHeartbeat(options) {
491
+ if (!hasServerIdentity(options?.credentials)) {
196
492
  return;
197
493
  }
198
- const { installToken, apiKey, clientId, clientLabel, hostType, serverName } = credentials();
494
+ const { installToken, apiKey, clientId, clientLabel, hostType, serverName } = credentials(options?.credentials);
199
495
  if (!installToken) {
200
496
  return;
201
497
  }
202
498
  const scope = resolvePluginScope(process.cwd());
203
- await fetch(`${apiBaseUrl()}/api/connectors/mcp/heartbeat`, {
499
+ const fetcher = options?.fetchImpl ?? fetch;
500
+ await fetcher(`${apiBaseUrl(options?.apiBaseUrl)}/api/connectors/mcp/heartbeat`, {
204
501
  method: "POST",
205
502
  headers: {
206
503
  "Content-Type": "application/json",
@@ -219,37 +516,254 @@ async function sendStartupHeartbeat() {
219
516
  }),
220
517
  }).catch(() => null);
221
518
  }
222
- export function createAskTheWMcpServer() {
519
+ async function sendStartupSetupSignal(options) {
520
+ const sessionSignal = {
521
+ sessionId: startupSessionId(options),
522
+ sequence: 0,
523
+ kind: "setup_complete",
524
+ summary: "Ask The W plugin server started for this coding-agent session.",
525
+ evidence: [],
526
+ filesTouched: [],
527
+ commandsRun: [],
528
+ metadata: {
529
+ ...runtimeMetadata(options),
530
+ automated: true,
531
+ operational: true,
532
+ origin: "mcp_server_startup",
533
+ },
534
+ };
535
+ await postToServer("/api/ingest/mcp", {
536
+ sessionSignal: redactCodingSessionSignal(sessionSignal),
537
+ }, options);
538
+ }
539
+ async function sendStartupSignals(options) {
540
+ await sendStartupHeartbeat(options).catch(() => null);
541
+ await sendStartupSetupSignal(options).catch(() => null);
542
+ }
543
+ export function createAskTheWMcpServer(options = {}) {
544
+ const initialMode = resolveMcpMode();
545
+ if (initialMode.mode === "free_pending_auth") {
546
+ ensureLocalIdentity({
547
+ emailClaim: process.env.ASKTHEW_EMAIL,
548
+ apiUrl: options.apiBaseUrl,
549
+ telemetryOptOut: process.env.ASKTHEW_TELEMETRY === "off",
550
+ });
551
+ }
552
+ const resolvedMode = resolveMcpMode();
553
+ const optionInstallToken = normalizeInstallTokenInput(options.credentials?.installToken);
554
+ const mode = optionInstallToken
555
+ ? { mode: "paid", installToken: optionInstallToken, reason: "options_install_token" }
556
+ : resolvedMode;
557
+ const localStore = mode.mode === "paid" ? null : LocalStore.open();
558
+ if (localStore && mode.mode === "free" && mode.cliCredentials) {
559
+ void flushTelemetryOutbox({
560
+ store: localStore,
561
+ credentials: mode.cliCredentials,
562
+ apiUrl: options.apiBaseUrl,
563
+ fetchImpl: options.fetchImpl,
564
+ }).catch(() => null);
565
+ }
223
566
  const server = new McpServer({
224
- name: "AskTheW Coding Agent Connector",
225
- version: "1.0.0",
567
+ name: "Ask The W Coding Agent Connector",
568
+ version: "0.4.0",
226
569
  });
227
- void sendStartupHeartbeat();
570
+ if (options.sendStartupHeartbeat !== false && mode.mode === "paid") {
571
+ void sendStartupSignals(options);
572
+ }
573
+ const apiToolResponse = async (route, payload = {}, method = "GET", request) => {
574
+ const upstream = await postToServer(route, payload, options, { method, idempotencyKey: request?.idempotencyKey });
575
+ return {
576
+ content: [
577
+ {
578
+ type: "text",
579
+ text: JSON.stringify(upstream, null, 2),
580
+ },
581
+ ],
582
+ };
583
+ };
584
+ const localResponse = (value) => toolJson(value);
585
+ const localToolError = (input) => localResponse(structuredError(input));
586
+ const budgetedLocalResponse = (value, maxChars) => {
587
+ if (!maxChars || JSON.stringify(value, null, 2).length <= maxChars) {
588
+ return localResponse(value);
589
+ }
590
+ const next = { ...value, truncated: true, maxChars };
591
+ for (const key of ["signals", "decisions", "decisionCandidates", "matches"]) {
592
+ const list = next[key];
593
+ if (Array.isArray(list)) {
594
+ while (list.length > 0 && JSON.stringify(next, null, 2).length > maxChars) {
595
+ list.pop();
596
+ }
597
+ next[key] = list;
598
+ }
599
+ }
600
+ if (typeof next.rendered === "string" && JSON.stringify(next, null, 2).length > maxChars) {
601
+ const overhead = JSON.stringify({ ...next, rendered: "" }, null, 2).length + 80;
602
+ next.rendered = `${next.rendered.slice(0, Math.max(0, maxChars - overhead)).trimEnd()}\n\n[truncated to max_chars=${maxChars}]`;
603
+ }
604
+ return localResponse(next);
605
+ };
606
+ const currentScopeKey = () => localScopeKey();
607
+ const compactWriteResponse = (input) => localResponse({
608
+ ok: input.ok !== false,
609
+ id: input.id ?? null,
610
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
611
+ ...(typeof input.sequence === "number" ? { sequence: input.sequence } : {}),
612
+ ...(typeof input.signalCount === "number" ? { signalCount: input.signalCount } : {}),
613
+ ...(input.warnings && input.warnings.length > 0 ? { warnings: input.warnings } : {}),
614
+ });
615
+ const upstreamId = (upstream) => {
616
+ if (!upstream || typeof upstream !== "object")
617
+ return null;
618
+ const record = upstream;
619
+ return (record.id ??
620
+ record.signalId ??
621
+ record.entry?.id ??
622
+ record.decision?.id ??
623
+ record.data?.id ??
624
+ record.data?.outcome?.id ??
625
+ record.data?.decision?.id ??
626
+ null);
627
+ };
628
+ const upstreamSequence = (upstream) => {
629
+ if (!upstream || typeof upstream !== "object")
630
+ return null;
631
+ const record = upstream;
632
+ const sequence = record.sequence ?? record.entry?.sequence ?? record.data?.sequence ?? null;
633
+ return typeof sequence === "number" ? sequence : null;
634
+ };
635
+ const upstreamFailure = (upstream) => upstream && typeof upstream === "object" && upstream.ok === false
636
+ ? upstream
637
+ : null;
638
+ const requireFreeIdentity = () => {
639
+ if (mode.mode === "unauthenticated" || mode.mode === "free_pending_auth") {
640
+ const loginCommand = loginCommandHint();
641
+ return localToolError({
642
+ code: "free_tier_login_required",
643
+ message: "Free local mode needs a local install identity before capture.",
644
+ retryable: false,
645
+ hint: `Run \`${loginCommand}\` or reinstall with \`--free --email <your-email>\`, then restart or reload the MCP host.`,
646
+ extra: {
647
+ loginCommand,
648
+ supportEmail: "support@askthew.com",
649
+ },
650
+ });
651
+ }
652
+ return null;
653
+ };
228
654
  server.tool("capture_session_signal", {
229
655
  sessionId: z.string().min(1),
230
656
  sequence: z.number().int().nonnegative(),
231
657
  kind: sessionSignalKindSchema,
232
658
  summary: z.string().min(1).max(2000),
233
659
  evidence: z
234
- .array(z.object({
235
- role: evidenceRoleSchema,
236
- excerpt: z.string().min(1).max(500),
237
- }))
660
+ .array(evidenceEntrySchema)
238
661
  .default([]),
239
662
  filesTouched: z.array(z.string().min(1).max(500)).default([]),
240
663
  commandsRun: z.array(z.string().min(1).max(500)).default([]),
241
664
  metadata: z.record(z.string(), z.unknown()).default({}),
665
+ idempotencyKey: idempotencyKeySchema,
666
+ echo: echoSchema,
242
667
  }, async (payload) => {
243
668
  const sessionSignal = redactCodingSessionSignal({
244
669
  ...payload,
245
670
  metadata: {
246
- ...runtimeMetadata(),
671
+ ...runtimeMetadata(options),
247
672
  ...(payload.metadata ?? {}),
248
673
  },
249
674
  });
250
- const upstream = await postToServer("/api/ingest/mcp", {
675
+ const scopeKey = currentScopeKey();
676
+ if (mode.mode !== "paid" && localStore) {
677
+ const loginRequired = requireFreeIdentity();
678
+ if (loginRequired) {
679
+ return loginRequired;
680
+ }
681
+ const signal = localStore.insertSignal({
682
+ sessionId: sessionSignal.sessionId,
683
+ sequence: sessionSignal.sequence,
684
+ kind: sessionSignal.kind,
685
+ summary: sessionSignal.summary,
686
+ evidence: sessionSignal.evidence,
687
+ filesTouched: sessionSignal.filesTouched,
688
+ commandsRun: sessionSignal.commandsRun,
689
+ metadata: sessionSignal.metadata,
690
+ scopeKey,
691
+ });
692
+ const sessionSignalCount = localStore.listSignals({
693
+ sessionId: signal.sessionId,
694
+ scopeKey,
695
+ limit: 100000,
696
+ }).length;
697
+ if (sessionSignal.kind === "final_summary" && mode.cliCredentials) {
698
+ localStore.enqueueTelemetry(buildTelemetryPayload({
699
+ store: localStore,
700
+ credentials: mode.cliCredentials,
701
+ sessionId: sessionSignal.sessionId,
702
+ }));
703
+ void flushTelemetryOutbox({
704
+ store: localStore,
705
+ credentials: mode.cliCredentials,
706
+ apiUrl: options.apiBaseUrl,
707
+ fetchImpl: options.fetchImpl,
708
+ }).catch(() => null);
709
+ }
710
+ if (!payload.echo) {
711
+ return compactWriteResponse({
712
+ id: signal.id,
713
+ sessionId: signal.sessionId,
714
+ sequence: signal.sequence,
715
+ signalCount: sessionSignalCount,
716
+ });
717
+ }
718
+ if (payload.echo === "summary") {
719
+ return localResponse({
720
+ ok: true,
721
+ id: signal.id,
722
+ sessionId: signal.sessionId,
723
+ sequence: signal.sequence,
724
+ signalCount: sessionSignalCount,
725
+ summary: signal.summary,
726
+ kind: signal.kind,
727
+ });
728
+ }
729
+ return localResponse({
730
+ ok: true,
731
+ tier: "free",
732
+ signal,
733
+ note: localStore.usingJsonFallback
734
+ ? "Captured locally in JSON fallback mode because SQLite was unavailable."
735
+ : "Captured locally in SQLite. Aggregate telemetry only may flush on final_summary unless opted out.",
736
+ });
737
+ }
738
+ const upstream = await postToServer(payload.echo === "full" ? "/api/ingest/mcp" : withResponseShape("/api/ingest/mcp"), {
251
739
  sessionSignal,
252
- });
740
+ }, options, { idempotencyKey: payload.idempotencyKey });
741
+ const failure = upstreamFailure(upstream);
742
+ if (failure)
743
+ return localResponse(failure);
744
+ if (!payload.echo) {
745
+ return compactWriteResponse({
746
+ id: upstreamId(upstream),
747
+ sessionId: sessionSignal.sessionId,
748
+ sequence: sessionSignal.sequence,
749
+ signalCount: upstream && typeof upstream === "object" && typeof upstream.signalCount === "number"
750
+ ? upstream.signalCount
751
+ : 1,
752
+ });
753
+ }
754
+ if (payload.echo === "summary") {
755
+ return localResponse({
756
+ ok: true,
757
+ id: upstreamId(upstream),
758
+ sessionId: sessionSignal.sessionId,
759
+ sequence: sessionSignal.sequence,
760
+ signalCount: upstream && typeof upstream === "object" && typeof upstream.signalCount === "number"
761
+ ? upstream.signalCount
762
+ : 1,
763
+ summary: sessionSignal.summary,
764
+ kind: sessionSignal.kind,
765
+ });
766
+ }
253
767
  return {
254
768
  content: [
255
769
  {
@@ -264,5 +778,1124 @@ export function createAskTheWMcpServer() {
264
778
  ],
265
779
  };
266
780
  });
781
+ server.tool("list_decisions", {
782
+ limit: z.number().int().positive().max(300).optional(),
783
+ cursor: z.string().optional(),
784
+ sessionId: z.string().optional(),
785
+ status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
786
+ since: z.string().optional(),
787
+ compact: z.boolean().optional(),
788
+ max_chars: maxCharsSchema,
789
+ }, async (payload) => {
790
+ if (mode.mode !== "paid" && localStore) {
791
+ const loginRequired = requireFreeIdentity();
792
+ if (loginRequired)
793
+ return loginRequired;
794
+ const decisions = localStore.listDecisions({
795
+ limit: payload.limit ?? 5,
796
+ cursor: payload.cursor,
797
+ sessionId: payload.sessionId,
798
+ status: payload.status,
799
+ since: payload.since,
800
+ scopeKey: currentScopeKey(),
801
+ });
802
+ return budgetedLocalResponse({
803
+ ok: true,
804
+ tier: "free",
805
+ decisions: payload.compact !== false
806
+ ? decisions.map((decision) => ({
807
+ id: decision.id,
808
+ headline: decision.headline,
809
+ status: decision.status,
810
+ signalIds: decision.sourceSignalIds,
811
+ }))
812
+ : decisions.map((decision) => decisionWithSignals(localStore, decision)),
813
+ nextCursor: decisions.length >= (payload.limit ?? 5) ? decisions.at(-1)?.createdAt ?? null : null,
814
+ }, payload.max_chars ?? 8000);
815
+ }
816
+ return apiToolResponse(routeWithQuery("/api/decisions", {
817
+ limit: payload.limit ?? 5,
818
+ cursor: payload.cursor,
819
+ sessionId: payload.sessionId,
820
+ status: payload.status,
821
+ since: payload.since,
822
+ compact: payload.compact ?? true,
823
+ max_chars: payload.max_chars ?? 8000,
824
+ }));
825
+ });
826
+ server.tool("get_decision", {
827
+ id: z.string().min(1),
828
+ }, async (payload) => {
829
+ if (mode.mode !== "paid" && localStore) {
830
+ const loginRequired = requireFreeIdentity();
831
+ if (loginRequired)
832
+ return loginRequired;
833
+ const decision = localStore.getDecision(payload.id);
834
+ return decision
835
+ ? localResponse({ ok: true, tier: "free", decision: decisionWithSignals(localStore, decision) })
836
+ : localToolError({
837
+ code: "not_found",
838
+ message: "Decision not found in the local Ask The W store.",
839
+ retryable: false,
840
+ hint: "Check the decision id or search/list local decisions first.",
841
+ });
842
+ }
843
+ return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`);
844
+ });
845
+ server.tool("create_decision", {
846
+ content: z.string().min(1),
847
+ idempotencyKey: idempotencyKeySchema,
848
+ echo: echoSchema,
849
+ }, async (payload) => {
850
+ if (mode.mode !== "paid" && localStore) {
851
+ const loginRequired = requireFreeIdentity();
852
+ if (loginRequired)
853
+ return loginRequired;
854
+ const scopeKey = currentScopeKey();
855
+ if (payload.idempotencyKey) {
856
+ const existingId = localStore.getMeta(`idempotency:create_decision:${scopeKey}:${payload.idempotencyKey}`);
857
+ if (existingId) {
858
+ const existing = localStore.getDecision(existingId);
859
+ if (existing) {
860
+ return payload.echo === "full"
861
+ ? localResponse({ ok: true, tier: "free", decision: decisionWithSignals(localStore, existing), idempotent: true })
862
+ : compactWriteResponse({ id: existing.id, sequence: localStore.stats().decisions });
863
+ }
864
+ }
865
+ }
866
+ const decision = localStore.createDecision({
867
+ rawContent: payload.content,
868
+ sessionId: localStore.mostRecentSessionId({ scopeKey }),
869
+ scopeKey,
870
+ });
871
+ if (payload.idempotencyKey) {
872
+ localStore.setMeta(`idempotency:create_decision:${scopeKey}:${payload.idempotencyKey}`, decision.id);
873
+ }
874
+ const warnings = detectDecisionConflicts({
875
+ decision,
876
+ decisions: localStore.listDecisions({ limit: 100000, scopeKey }),
877
+ });
878
+ if (!payload.echo) {
879
+ return compactWriteResponse({
880
+ id: decision.id,
881
+ sequence: localStore.stats().decisions,
882
+ warnings,
883
+ });
884
+ }
885
+ return localResponse({
886
+ ok: true,
887
+ ...(payload.echo === "summary"
888
+ ? { id: decision.id, sequence: localStore.stats().decisions, headline: decision.headline, warnings }
889
+ : { tier: "free", decision: decisionWithSignals(localStore, decision), warnings }),
890
+ });
891
+ }
892
+ const upstream = await postToServer(payload.echo === "full" ? "/api/decisions" : withResponseShape("/api/decisions"), {
893
+ content: payload.content,
894
+ }, options, { method: "POST", idempotencyKey: payload.idempotencyKey });
895
+ const failure = upstreamFailure(upstream);
896
+ if (failure)
897
+ return localResponse(failure);
898
+ if (!payload.echo) {
899
+ return compactWriteResponse({
900
+ id: upstreamId(upstream),
901
+ sequence: upstreamSequence(upstream) ?? 1,
902
+ });
903
+ }
904
+ return localResponse(payload.echo === "summary"
905
+ ? { ok: true, id: upstreamId(upstream), sequence: upstreamSequence(upstream) ?? 1 }
906
+ : upstream);
907
+ });
908
+ server.tool("update_decision", {
909
+ id: z.string().min(1),
910
+ headline: z.string().min(1).optional(),
911
+ why: z.string().optional(),
912
+ status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
913
+ alignment: z.enum(["aligned", "orthogonal", "conflicts", "ambiguous"]).optional(),
914
+ outcomeId: z.string().min(1).optional(),
915
+ idempotencyKey: idempotencyKeySchema,
916
+ echo: echoSchema,
917
+ }, async (payload) => {
918
+ if (mode.mode !== "paid" && localStore) {
919
+ const loginRequired = requireFreeIdentity();
920
+ if (loginRequired)
921
+ return loginRequired;
922
+ const decision = localStore.updateDecision(payload.id, {
923
+ ...(payload.headline ? { headline: payload.headline } : {}),
924
+ ...(payload.why !== undefined ? { why: payload.why } : {}),
925
+ ...(payload.status ? { status: payload.status } : {}),
926
+ ...(payload.alignment !== undefined ? { alignment: payload.alignment } : {}),
927
+ });
928
+ if (!decision) {
929
+ return localToolError({
930
+ code: "not_found",
931
+ message: "Decision not found in the local Ask The W store.",
932
+ retryable: false,
933
+ hint: "Check the decision id before updating.",
934
+ });
935
+ }
936
+ const warnings = detectDecisionConflicts({
937
+ decision,
938
+ decisions: localStore.listDecisions({ limit: 100000, scopeKey: currentScopeKey() }),
939
+ });
940
+ if (!payload.echo) {
941
+ return compactWriteResponse({ id: decision.id, sequence: localStore.stats().decisions, warnings });
942
+ }
943
+ return localResponse(payload.echo === "summary"
944
+ ? { ok: true, id: decision.id, sequence: localStore.stats().decisions, headline: decision.headline, warnings }
945
+ : { ok: true, tier: "free", decision: decisionWithSignals(localStore, decision), warnings });
946
+ }
947
+ const upstream = await postToServer(payload.echo === "full"
948
+ ? `/api/decisions/${encodeURIComponent(payload.id)}`
949
+ : withResponseShape(`/api/decisions/${encodeURIComponent(payload.id)}`), {
950
+ headline: payload.headline,
951
+ why: payload.why,
952
+ status: payload.status,
953
+ alignment: payload.alignment,
954
+ outcomeId: payload.outcomeId,
955
+ }, options, { method: "PATCH", idempotencyKey: payload.idempotencyKey });
956
+ const failure = upstreamFailure(upstream);
957
+ if (failure)
958
+ return localResponse(failure);
959
+ if (!payload.echo) {
960
+ return compactWriteResponse({ id: upstreamId(upstream) ?? payload.id, sequence: upstreamSequence(upstream) ?? 1 });
961
+ }
962
+ return localResponse(payload.echo === "summary"
963
+ ? { ok: true, id: upstreamId(upstream) ?? payload.id, sequence: upstreamSequence(upstream) ?? 1 }
964
+ : upstream);
965
+ });
966
+ server.tool("delete_decision", {
967
+ id: z.string().min(1),
968
+ confirmText: z.string().min(1),
969
+ idempotencyKey: idempotencyKeySchema,
970
+ }, async (payload) => {
971
+ if (mode.mode !== "paid" && localStore) {
972
+ const loginRequired = requireFreeIdentity();
973
+ if (loginRequired)
974
+ return loginRequired;
975
+ if (payload.confirmText !== payload.id) {
976
+ return localToolError({
977
+ code: "confirmation_required",
978
+ message: "confirmText must match the decision id.",
979
+ retryable: false,
980
+ hint: "Pass the exact decision id as confirmText.",
981
+ });
982
+ }
983
+ return localResponse({ ok: localStore.deleteDecision(payload.id), tier: "free" });
984
+ }
985
+ return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`, {
986
+ confirmText: payload.confirmText,
987
+ }, "DELETE", { idempotencyKey: payload.idempotencyKey });
988
+ });
989
+ server.tool("list_outcomes", paidDescription("List outcomes from your workspace.", mode.mode), {
990
+ limit: z.number().int().positive().max(300).optional(),
991
+ cursor: cursorSchema,
992
+ }, async (payload) => {
993
+ if (mode.mode === "free")
994
+ return localResponse(paidFeatureNudge("list_outcomes"));
995
+ return apiToolResponse(routeWithQuery("/api/outcomes", {
996
+ limit: payload.limit,
997
+ cursor: payload.cursor,
998
+ }));
999
+ });
1000
+ server.tool("get_outcome", paidDescription("Get outcome detail from your workspace.", mode.mode), {
1001
+ id: z.string().min(1),
1002
+ }, async (payload) => mode.mode === "free"
1003
+ ? localResponse(paidFeatureNudge("get_outcome"))
1004
+ : apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`));
1005
+ server.tool("list_outcome_signals", paidDescription("List signals linked to an outcome.", mode.mode), {
1006
+ id: z.string().min(1),
1007
+ limit: z.number().int().positive().max(300).optional(),
1008
+ cursor: cursorSchema,
1009
+ }, async (payload) => mode.mode === "free"
1010
+ ? localResponse(paidFeatureNudge("list_outcome_signals"))
1011
+ : apiToolResponse(routeWithQuery(`/api/outcomes/${encodeURIComponent(payload.id)}/signals`, {
1012
+ limit: payload.limit,
1013
+ cursor: payload.cursor,
1014
+ })));
1015
+ server.tool("create_outcome", paidDescription("Create a new outcome.", mode.mode), {
1016
+ name: z.string().min(1),
1017
+ summary: z.string().optional(),
1018
+ causalHypothesis: z.string().optional(),
1019
+ suggestedAction: z.string().optional(),
1020
+ idempotencyKey: idempotencyKeySchema,
1021
+ }, async (payload) => {
1022
+ if (mode.mode === "free")
1023
+ return localResponse(paidFeatureNudge("create_outcome"));
1024
+ return apiToolResponse("/api/outcomes", {
1025
+ name: payload.name,
1026
+ summary: payload.summary,
1027
+ causalHypothesis: payload.causalHypothesis,
1028
+ suggestedAction: payload.suggestedAction,
1029
+ }, "POST", { idempotencyKey: payload.idempotencyKey });
1030
+ });
1031
+ server.tool("update_outcome", paidDescription("Update an outcome.", mode.mode), {
1032
+ id: z.string().min(1),
1033
+ name: z.string().min(1).optional(),
1034
+ summary: z.string().optional(),
1035
+ causalHypothesis: z.string().optional(),
1036
+ suggestedAction: z.string().optional(),
1037
+ status: z.enum(["active", "achieved", "abandoned", "archived"]).optional(),
1038
+ idempotencyKey: idempotencyKeySchema,
1039
+ echo: echoSchema,
1040
+ }, async (payload) => {
1041
+ if (mode.mode === "free")
1042
+ return localResponse(paidFeatureNudge("update_outcome"));
1043
+ const upstream = await postToServer(payload.echo === "full"
1044
+ ? `/api/outcomes/${encodeURIComponent(payload.id)}`
1045
+ : withResponseShape(`/api/outcomes/${encodeURIComponent(payload.id)}`), {
1046
+ name: payload.name,
1047
+ summary: payload.summary,
1048
+ causalHypothesis: payload.causalHypothesis,
1049
+ suggestedAction: payload.suggestedAction,
1050
+ status: payload.status,
1051
+ }, options, { method: "PATCH", idempotencyKey: payload.idempotencyKey });
1052
+ const failure = upstreamFailure(upstream);
1053
+ if (failure)
1054
+ return localResponse(failure);
1055
+ if (!payload.echo) {
1056
+ return compactWriteResponse({ id: upstreamId(upstream) ?? payload.id, sequence: upstreamSequence(upstream) ?? 1 });
1057
+ }
1058
+ return localResponse(payload.echo === "summary"
1059
+ ? { ok: true, id: upstreamId(upstream) ?? payload.id, sequence: upstreamSequence(upstream) ?? 1 }
1060
+ : upstream);
1061
+ });
1062
+ server.tool("delete_outcome", paidDescription("Delete an outcome.", mode.mode), {
1063
+ id: z.string().min(1),
1064
+ confirmText: z.string().min(1),
1065
+ idempotencyKey: idempotencyKeySchema,
1066
+ }, async (payload) => {
1067
+ if (mode.mode === "free")
1068
+ return localResponse(paidFeatureNudge("delete_outcome"));
1069
+ return apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`, {
1070
+ confirmText: payload.confirmText,
1071
+ }, "DELETE", { idempotencyKey: payload.idempotencyKey });
1072
+ });
1073
+ server.tool("get_north_star", paidDescription("Read the workspace north-star metric.", mode.mode), {}, async () => mode.mode === "free"
1074
+ ? localResponse(paidFeatureNudge("get_north_star"))
1075
+ : apiToolResponse("/api/north-star"));
1076
+ server.tool("update_north_star", paidDescription("Update the workspace north-star metric.", mode.mode), {
1077
+ metric: z.string().min(1),
1078
+ current: z.string().min(1),
1079
+ target: z.string().min(1),
1080
+ reason: z.string().min(1),
1081
+ idempotencyKey: idempotencyKeySchema,
1082
+ }, async (payload) => {
1083
+ if (mode.mode === "free")
1084
+ return localResponse(paidFeatureNudge("update_north_star"));
1085
+ return apiToolResponse("/api/north-star", {
1086
+ metric: payload.metric,
1087
+ current: payload.current,
1088
+ target: payload.target,
1089
+ reason: payload.reason,
1090
+ }, "POST", { idempotencyKey: payload.idempotencyKey });
1091
+ });
1092
+ server.tool("list_signals", {
1093
+ limit: z.number().int().positive().max(300).optional(),
1094
+ cursor: z.string().optional(),
1095
+ sessionId: z.string().optional(),
1096
+ since: z.string().optional(),
1097
+ compact: z.boolean().optional(),
1098
+ max_chars: maxCharsSchema,
1099
+ }, async (payload) => {
1100
+ if (mode.mode !== "paid" && localStore) {
1101
+ const loginRequired = requireFreeIdentity();
1102
+ if (loginRequired)
1103
+ return loginRequired;
1104
+ const signals = localStore.listSignals({
1105
+ limit: payload.limit ?? 10,
1106
+ cursor: payload.cursor,
1107
+ sessionId: payload.sessionId,
1108
+ since: payload.since,
1109
+ scopeKey: currentScopeKey(),
1110
+ });
1111
+ return budgetedLocalResponse({
1112
+ ok: true,
1113
+ tier: "free",
1114
+ signals: payload.compact !== false
1115
+ ? signals.map((signal) => compactSignal(signal, localStore.getDecisionForSignal(signal.id)))
1116
+ : signals.map((signal) => signalWithDecision(localStore, signal)),
1117
+ nextCursor: signals.length >= (payload.limit ?? 10) ? signals.at(-1)?.capturedAt ?? null : null,
1118
+ }, payload.max_chars ?? 8000);
1119
+ }
1120
+ return apiToolResponse(routeWithQuery("/api/signals", {
1121
+ limit: payload.limit ?? 10,
1122
+ cursor: payload.cursor,
1123
+ sessionId: payload.sessionId,
1124
+ since: payload.since,
1125
+ compact: payload.compact ?? true,
1126
+ max_chars: payload.max_chars ?? 8000,
1127
+ }));
1128
+ });
1129
+ server.tool("find_signal_by_summary", "Find recent signals by summary text without needing an opaque signal id first.", {
1130
+ query: z.string().min(1),
1131
+ sessionId: z.string().optional(),
1132
+ limit: z.number().int().positive().max(50).default(5),
1133
+ compact: z.boolean().optional(),
1134
+ max_chars: maxCharsSchema,
1135
+ }, async (payload) => {
1136
+ if (mode.mode !== "paid" && localStore) {
1137
+ const loginRequired = requireFreeIdentity();
1138
+ if (loginRequired)
1139
+ return loginRequired;
1140
+ const normalizedQuery = payload.query.toLowerCase();
1141
+ const signals = localStore
1142
+ .listSignals({
1143
+ limit: 100000,
1144
+ sessionId: payload.sessionId,
1145
+ scopeKey: currentScopeKey(),
1146
+ })
1147
+ .filter((signal) => [
1148
+ signal.summary,
1149
+ signal.kind,
1150
+ ...signal.filesTouched,
1151
+ ...signal.commandsRun,
1152
+ ].join("\n").toLowerCase().includes(normalizedQuery))
1153
+ .slice(0, payload.limit);
1154
+ return budgetedLocalResponse({
1155
+ ok: true,
1156
+ tier: "free",
1157
+ query: payload.query,
1158
+ signals: payload.compact === false
1159
+ ? signals.map((signal) => signalWithDecision(localStore, signal))
1160
+ : signals.map((signal) => compactSignal(signal, localStore.getDecisionForSignal(signal.id))),
1161
+ }, payload.max_chars ?? 8000);
1162
+ }
1163
+ return apiToolResponse(routeWithQuery("/api/signals", {
1164
+ query: payload.query,
1165
+ sessionId: payload.sessionId,
1166
+ limit: payload.limit,
1167
+ compact: payload.compact ?? true,
1168
+ max_chars: payload.max_chars ?? 8000,
1169
+ }));
1170
+ });
1171
+ server.tool("get_signal", {
1172
+ id: z.string().min(1),
1173
+ }, async (payload) => {
1174
+ if (mode.mode !== "paid" && localStore) {
1175
+ const loginRequired = requireFreeIdentity();
1176
+ if (loginRequired)
1177
+ return loginRequired;
1178
+ const signal = localStore.getSignal(Number(payload.id));
1179
+ return signal
1180
+ ? localResponse({ ok: true, tier: "free", signal: signalWithDecision(localStore, signal) })
1181
+ : localToolError({
1182
+ code: "not_found",
1183
+ message: "Signal not found in the local Ask The W store.",
1184
+ retryable: false,
1185
+ hint: "Check the signal id or list local signals first.",
1186
+ });
1187
+ }
1188
+ return apiToolResponse(`/api/signals/${encodeURIComponent(payload.id)}`);
1189
+ });
1190
+ server.tool("review_decisions", "Review captured decisions. Use for natural prompts like: What did I decide yesterday?", {
1191
+ since: z.string().optional(),
1192
+ status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
1193
+ format: z.enum(["markdown", "json"]).default("markdown"),
1194
+ limit: z.number().int().positive().max(300).default(50),
1195
+ cursor: cursorSchema,
1196
+ sessionId: z.string().optional(),
1197
+ compact: z.boolean().optional(),
1198
+ max_chars: maxCharsSchema,
1199
+ }, async (payload) => {
1200
+ if (mode.mode === "paid") {
1201
+ return apiToolResponse(routeWithQuery("/api/decisions", {
1202
+ since: payload.since,
1203
+ status: payload.status,
1204
+ limit: payload.limit,
1205
+ cursor: payload.cursor,
1206
+ sessionId: payload.sessionId,
1207
+ compact: payload.compact,
1208
+ max_chars: payload.max_chars,
1209
+ }));
1210
+ }
1211
+ if (!localStore) {
1212
+ return localToolError({
1213
+ code: "local_store_unavailable",
1214
+ message: "The local Ask The W store is unavailable.",
1215
+ retryable: true,
1216
+ hint: "Retry after restarting the plugin host.",
1217
+ });
1218
+ }
1219
+ const loginRequired = requireFreeIdentity();
1220
+ if (loginRequired)
1221
+ return loginRequired;
1222
+ const decisions = localStore.listDecisions({
1223
+ since: payload.since,
1224
+ status: payload.status,
1225
+ limit: payload.limit,
1226
+ cursor: payload.cursor,
1227
+ sessionId: payload.sessionId,
1228
+ scopeKey: currentScopeKey(),
1229
+ });
1230
+ return budgetedLocalResponse({
1231
+ ok: true,
1232
+ tier: "free",
1233
+ format: payload.format,
1234
+ rendered: renderDecisionDigest(decisions),
1235
+ decisions: payload.compact
1236
+ ? decisions.map((decision) => ({
1237
+ id: decision.id,
1238
+ headline: decision.headline,
1239
+ status: decision.status,
1240
+ signalIds: decision.sourceSignalIds,
1241
+ }))
1242
+ : decisions.map((decision) => decisionWithSignals(localStore, decision)),
1243
+ count: decisions.length,
1244
+ nextCursor: decisions.length >= payload.limit ? decisions.at(-1)?.createdAt ?? null : null,
1245
+ copyHint: "Copy this output to back up your decisions - `export_decisions` is a paid feature.",
1246
+ }, payload.max_chars);
1247
+ });
1248
+ server.tool("review_session", "Review the current session trail. Use for natural prompts like: Show me my session trail.", {
1249
+ sessionId: z.string().optional(),
1250
+ format: z.enum(["markdown", "json"]).default("markdown"),
1251
+ cursor: cursorSchema,
1252
+ limit: z.number().int().positive().max(50).default(50),
1253
+ compact: z.boolean().optional(),
1254
+ max_chars: maxCharsSchema,
1255
+ }, async (payload) => {
1256
+ if (!localStore || mode.mode === "paid") {
1257
+ return apiToolResponse(routeWithQuery("/api/signals", {
1258
+ sessionId: payload.sessionId,
1259
+ cursor: payload.cursor,
1260
+ limit: payload.limit,
1261
+ compact: payload.compact,
1262
+ max_chars: payload.max_chars,
1263
+ }));
1264
+ }
1265
+ const loginRequired = requireFreeIdentity();
1266
+ if (loginRequired)
1267
+ return loginRequired;
1268
+ const scopeKey = currentScopeKey();
1269
+ const sessionId = payload.sessionId ?? localStore.mostRecentSessionId({ scopeKey });
1270
+ const allSessionIds = localStore.listSessionIds({ limit: 100000, scopeKey });
1271
+ const allowedSessionIds = new Set(allSessionIds.slice(0, 3));
1272
+ if (sessionId && allSessionIds.length > 3 && !allowedSessionIds.has(sessionId)) {
1273
+ return localToolError({
1274
+ code: "free_tier_limit",
1275
+ message: "The free plugin can review the latest three local sessions.",
1276
+ retryable: false,
1277
+ hint: "Upgrade to review more than three sessions in the workspace dashboard.",
1278
+ extra: {
1279
+ tool: "review_session",
1280
+ limit: 3,
1281
+ upgradeUrl: "https://askthew.com/mcp?utm_source=mcp-plugin&utm_medium=tool-nudge&utm_campaign=mcp-free&tool=review_session",
1282
+ cta: "Upgrade to review more than three sessions in the workspace dashboard.",
1283
+ },
1284
+ });
1285
+ }
1286
+ const limit = Math.min(50, payload.limit ?? 50);
1287
+ const signals = sessionId
1288
+ ? localStore.listSignals({ sessionId, scopeKey, cursor: payload.cursor, limit })
1289
+ : [];
1290
+ const allSignals = sessionId ? localStore.listSignals({ sessionId, scopeKey, limit: 100000 }) : [];
1291
+ const decisions = sessionId
1292
+ ? localStore.listDecisions({ sessionId, scopeKey, limit: 100000 })
1293
+ : [];
1294
+ const decisionCandidates = listDecisionCandidates({ store: localStore, sessionId, scopeKey, limit: 25 }).candidates;
1295
+ const counts = signals.reduce((accumulator, signal) => {
1296
+ accumulator[signal.kind] = (accumulator[signal.kind] ?? 0) + 1;
1297
+ return accumulator;
1298
+ }, {});
1299
+ const allCounts = allSignals.reduce((accumulator, signal) => {
1300
+ accumulator[signal.kind] = (accumulator[signal.kind] ?? 0) + 1;
1301
+ return accumulator;
1302
+ }, {});
1303
+ const nextCursor = signals.length >= limit ? signals.at(-1)?.capturedAt ?? null : null;
1304
+ if (payload.format === "json") {
1305
+ return budgetedLocalResponse({
1306
+ ok: true,
1307
+ tier: "free",
1308
+ sessionId,
1309
+ format: "json",
1310
+ signals: payload.compact
1311
+ ? signals.map((signal) => compactSignal(signal, localStore.getDecisionForSignal(signal.id)))
1312
+ : signals.map((signal) => signalWithDecision(localStore, signal)),
1313
+ decisions: payload.compact
1314
+ ? decisions.map((decision) => ({ id: decision.id, headline: decision.headline, status: decision.status, signalIds: decision.sourceSignalIds }))
1315
+ : decisions.map((decision) => decisionWithSignals(localStore, decision)),
1316
+ decisionCandidates,
1317
+ nextCursor,
1318
+ counts: {
1319
+ totalSignals: allSignals.length,
1320
+ byKind: allCounts,
1321
+ },
1322
+ }, payload.max_chars);
1323
+ }
1324
+ return budgetedLocalResponse({
1325
+ ok: true,
1326
+ tier: "free",
1327
+ sessionId,
1328
+ format: "markdown",
1329
+ rendered: renderSessionMarkdown({ sessionId, signals: allSignals, decisions, decisionCandidates }),
1330
+ ...(payload.compact
1331
+ ? { signals: allSignals.map((signal) => compactSignal(signal, localStore.getDecisionForSignal(signal.id))) }
1332
+ : {}),
1333
+ counts: {
1334
+ totalSignals: allSignals.length,
1335
+ byKind: Object.keys(allCounts).length ? allCounts : counts,
1336
+ },
1337
+ }, payload.max_chars);
1338
+ });
1339
+ server.tool("recap", "Summarize the latest local coding-agent session as a digest, standup, or share-ready recap.", {
1340
+ format: z.enum(["digest", "standup", "share"]).default("digest"),
1341
+ sessionId: z.string().optional(),
1342
+ compact: z.boolean().optional(),
1343
+ max_chars: maxCharsSchema,
1344
+ }, async (payload) => {
1345
+ if (!localStore || mode.mode === "paid")
1346
+ return localResponse(paidFeatureNudge("recap"));
1347
+ const loginRequired = requireFreeIdentity();
1348
+ if (loginRequired)
1349
+ return loginRequired;
1350
+ const scopeKey = currentScopeKey();
1351
+ const sessionId = payload.sessionId ?? localStore.mostRecentSessionId({ scopeKey });
1352
+ const signals = sessionId ? localStore.listSignals({ sessionId, scopeKey, limit: 100000 }) : [];
1353
+ const decisions = sessionId ? localStore.listDecisions({ sessionId, scopeKey, limit: 100000 }) : [];
1354
+ return budgetedLocalResponse({
1355
+ ok: true,
1356
+ tier: "free",
1357
+ sessionId,
1358
+ format: payload.format,
1359
+ rendered: renderRecap({ format: payload.format, signals, decisions }),
1360
+ ...(payload.compact
1361
+ ? { signals: signals.map((signal) => compactSignal(signal, localStore.getDecisionForSignal(signal.id))) }
1362
+ : {}),
1363
+ }, payload.max_chars);
1364
+ });
1365
+ server.tool("coach", "Coach the local coding-agent session. Use for natural prompts like: Coach me on this session.", {
1366
+ scope: z.enum(["session", "week", "patterns"]).default("session"),
1367
+ sessionId: z.string().optional(),
1368
+ max_chars: maxCharsSchema,
1369
+ }, async (payload) => {
1370
+ if (payload.scope === "week" || payload.scope === "patterns") {
1371
+ return localResponse(paidFeatureNudge("coach"));
1372
+ }
1373
+ if (!localStore || mode.mode === "paid")
1374
+ return localResponse(paidFeatureNudge("coach"));
1375
+ const loginRequired = requireFreeIdentity();
1376
+ if (loginRequired)
1377
+ return loginRequired;
1378
+ const scopeKey = currentScopeKey();
1379
+ const sessionId = payload.sessionId ?? localStore.mostRecentSessionId({ scopeKey });
1380
+ const signals = sessionId ? localStore.listSignals({ sessionId, scopeKey, limit: 100000 }) : [];
1381
+ const decisions = sessionId ? localStore.listDecisions({ sessionId, scopeKey, limit: 100000 }) : [];
1382
+ const coaching = buildSessionCoach({ signals, decisions });
1383
+ return budgetedLocalResponse({
1384
+ ok: true,
1385
+ tier: "free",
1386
+ scope: "session",
1387
+ sessionId,
1388
+ qualityScore: coaching.qualityScore,
1389
+ biggestGap: coaching.biggestGap,
1390
+ failureMode: coaching.failureMode,
1391
+ rendered: `Decision quality score: ${coaching.qualityScore}/100\nBiggest gap: ${coaching.biggestGap}`,
1392
+ }, payload.max_chars);
1393
+ });
1394
+ server.tool("promote_signal_to_decision", "Copy a captured signal summary into a linked local decision.", {
1395
+ signalId: z.union([z.string(), z.number()]),
1396
+ status: z.enum(["proposed", "committed", "shipped", "abandoned"]).default("proposed"),
1397
+ why: z.string().optional(),
1398
+ idempotencyKey: idempotencyKeySchema,
1399
+ }, async (payload) => {
1400
+ if (!localStore || mode.mode === "paid")
1401
+ return localResponse(paidFeatureNudge("promote_signal_to_decision"));
1402
+ const loginRequired = requireFreeIdentity();
1403
+ if (loginRequired)
1404
+ return loginRequired;
1405
+ const numericSignalId = typeof payload.signalId === "number" ? payload.signalId : Number(payload.signalId);
1406
+ if (!Number.isFinite(numericSignalId)) {
1407
+ return localToolError({
1408
+ code: "invalid_input",
1409
+ message: "Invalid signalId.",
1410
+ retryable: false,
1411
+ hint: "Use the numeric local signal id.",
1412
+ extra: { field: "signalId" },
1413
+ });
1414
+ }
1415
+ const signal = localStore.getSignal(numericSignalId);
1416
+ if (!signal) {
1417
+ return localToolError({
1418
+ code: "not_found",
1419
+ message: "Signal not found in the local Ask The W store.",
1420
+ retryable: false,
1421
+ hint: "List signals first, then pass the numeric local signal id.",
1422
+ extra: { tool: "promote_signal_to_decision" },
1423
+ });
1424
+ }
1425
+ if (payload.idempotencyKey) {
1426
+ const existingId = localStore.getMeta(`idempotency:promote_signal_to_decision:${payload.idempotencyKey}`);
1427
+ if (existingId) {
1428
+ const existing = localStore.getDecision(existingId);
1429
+ if (existing) {
1430
+ return localResponse({
1431
+ ok: true,
1432
+ id: existing.id,
1433
+ sequence: localStore.stats().decisions,
1434
+ decision: decisionWithSignals(localStore, existing),
1435
+ linkedSignalId: signal.id,
1436
+ idempotent: true,
1437
+ });
1438
+ }
1439
+ }
1440
+ }
1441
+ const decision = localStore.createDecision({
1442
+ rawContent: signal.summary,
1443
+ headline: signal.summary,
1444
+ why: payload.why ?? null,
1445
+ status: payload.status,
1446
+ sessionId: signal.sessionId,
1447
+ files: signal.filesTouched,
1448
+ sourceSignalIds: [signal.id],
1449
+ scopeKey: signal.scopeKey ?? currentScopeKey(),
1450
+ });
1451
+ if (payload.idempotencyKey) {
1452
+ localStore.setMeta(`idempotency:promote_signal_to_decision:${payload.idempotencyKey}`, decision.id);
1453
+ }
1454
+ const warnings = detectDecisionConflicts({
1455
+ decision,
1456
+ decisions: localStore.listDecisions({ limit: 100000, scopeKey: decision.scopeKey }),
1457
+ });
1458
+ return localResponse({
1459
+ ok: true,
1460
+ id: decision.id,
1461
+ sequence: localStore.stats().decisions,
1462
+ decision: decisionWithSignals(localStore, decision),
1463
+ linkedSignalId: signal.id,
1464
+ warnings,
1465
+ });
1466
+ });
1467
+ server.tool("list_decision_candidates", "List local signals that look like decision moments and can be promoted.", {
1468
+ sessionId: z.string().optional(),
1469
+ limit: z.number().int().positive().max(300).default(50),
1470
+ cursor: cursorSchema,
1471
+ max_chars: maxCharsSchema,
1472
+ }, async (payload) => {
1473
+ if (!localStore || mode.mode === "paid")
1474
+ return localResponse(paidFeatureNudge("list_decision_candidates"));
1475
+ const loginRequired = requireFreeIdentity();
1476
+ if (loginRequired)
1477
+ return loginRequired;
1478
+ const scopeKey = currentScopeKey();
1479
+ const sessionId = payload.sessionId ?? localStore.mostRecentSessionId({ scopeKey });
1480
+ const result = listDecisionCandidates({
1481
+ store: localStore,
1482
+ sessionId,
1483
+ scopeKey,
1484
+ limit: payload.limit,
1485
+ cursor: payload.cursor,
1486
+ });
1487
+ return budgetedLocalResponse({
1488
+ ok: true,
1489
+ tier: "free",
1490
+ sessionId,
1491
+ decisionCandidates: result.candidates,
1492
+ nextCursor: result.nextCursor,
1493
+ }, payload.max_chars);
1494
+ });
1495
+ server.tool("search_trail", "Search local signals and decisions together.", {
1496
+ query: z.string().min(1),
1497
+ sessionId: z.string().optional(),
1498
+ limit: z.number().int().positive().max(100).default(25),
1499
+ cursor: cursorSchema,
1500
+ compact: z.boolean().optional(),
1501
+ max_chars: maxCharsSchema,
1502
+ }, async (payload) => {
1503
+ if (!localStore || mode.mode === "paid")
1504
+ return localResponse(paidFeatureNudge("search_trail"));
1505
+ const loginRequired = requireFreeIdentity();
1506
+ if (loginRequired)
1507
+ return loginRequired;
1508
+ const result = searchTrail({
1509
+ store: localStore,
1510
+ query: payload.query,
1511
+ scopeKey: currentScopeKey(),
1512
+ sessionId: payload.sessionId,
1513
+ limit: payload.limit,
1514
+ cursor: payload.cursor,
1515
+ compact: payload.compact,
1516
+ });
1517
+ return budgetedLocalResponse({
1518
+ ok: true,
1519
+ tier: "free",
1520
+ query: payload.query,
1521
+ matches: result.matches,
1522
+ nextCursor: result.nextCursor,
1523
+ }, payload.max_chars);
1524
+ });
1525
+ server.tool("export_decisions", paidDescription("Export decisions from your workspace.", mode.mode), {
1526
+ format: z.enum(["json", "markdown", "jsonl"]).default("json"),
1527
+ cursor: cursorSchema,
1528
+ limit: z.number().int().positive().max(300).optional(),
1529
+ max_chars: maxCharsSchema,
1530
+ }, async (payload) => mode.mode === "free"
1531
+ ? localResponse(paidFeatureNudge("export_decisions"))
1532
+ : apiToolResponse(routeWithQuery("/api/export/timeline", {
1533
+ format: payload.format,
1534
+ cursor: payload.cursor,
1535
+ limit: payload.limit ?? 50,
1536
+ max_chars: payload.max_chars ?? 8000,
1537
+ })));
1538
+ server.tool("view_timeline", "View signals and decisions counts bucketed by session, day, or month.", {
1539
+ scope: z.enum(["day", "month", "session"]).default("day"),
1540
+ range: z.enum(["7D", "30D", "90D", "12M", "CUSTOM"]).default("30D"),
1541
+ start: z.string().optional(),
1542
+ end: z.string().optional(),
1543
+ limit: z.number().int().positive().max(300).optional(),
1544
+ outcomeId: z.string().optional(),
1545
+ decisionStatus: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
1546
+ signalSource: z.string().optional(),
1547
+ max_chars: maxCharsSchema,
1548
+ }, async (payload) => {
1549
+ if (mode.mode === "paid") {
1550
+ return apiToolResponse(routeWithQuery("/api/analytics/timeline-counts", {
1551
+ scope: payload.scope,
1552
+ range: payload.range,
1553
+ start: payload.start,
1554
+ end: payload.end,
1555
+ limit: payload.limit,
1556
+ outcomeId: payload.outcomeId,
1557
+ decisionStatus: payload.decisionStatus,
1558
+ signalSource: payload.signalSource,
1559
+ }));
1560
+ }
1561
+ if (!localStore) {
1562
+ return localToolError({
1563
+ code: "local_store_unavailable",
1564
+ message: "The local Ask The W store is unavailable.",
1565
+ retryable: true,
1566
+ hint: "Retry after restarting the plugin host.",
1567
+ });
1568
+ }
1569
+ const loginRequired = requireFreeIdentity();
1570
+ if (loginRequired)
1571
+ return loginRequired;
1572
+ const scopeKey = currentScopeKey();
1573
+ const points = buildLocalTimeline({
1574
+ scope: payload.scope,
1575
+ signals: localStore.listSignals({ scopeKey, limit: 100000 }),
1576
+ decisions: localStore.listDecisions({ scopeKey, limit: 100000 }),
1577
+ limit: payload.limit,
1578
+ });
1579
+ const totals = points.reduce((accumulator, point) => ({
1580
+ signals: accumulator.signals + point.signalCount,
1581
+ decisions: accumulator.decisions + point.decisionCount,
1582
+ }), { signals: 0, decisions: 0 });
1583
+ return budgetedLocalResponse({
1584
+ ok: true,
1585
+ tier: "free",
1586
+ scope: payload.scope,
1587
+ period: {
1588
+ start: points[0]?.startedAt ?? points[0]?.x ?? "",
1589
+ end: points.at(-1)?.endedAt ?? points.at(-1)?.x ?? "",
1590
+ label: "Local timeline",
1591
+ tz: "UTC",
1592
+ },
1593
+ points,
1594
+ totals,
1595
+ insights: buildLocalTimelineInsights(points),
1596
+ narrative: `Local timeline: ${totals.signals} signals, ${totals.decisions} decisions.`,
1597
+ markdownTable: renderLocalTimelineMarkdown(points, payload.scope),
1598
+ }, payload.max_chars);
1599
+ });
267
1600
  return server;
268
1601
  }
1602
+ function renderDecisionDigest(decisions) {
1603
+ if (decisions.length === 0) {
1604
+ return "# Decisions\n\nNo local decisions captured yet.";
1605
+ }
1606
+ return [
1607
+ "# Decisions",
1608
+ "",
1609
+ ...decisions.map((decision) => [`## ${decision.headline}`, `- id: ${decision.id}`, `- status: ${decision.status}`, `- created: ${decision.createdAt}`, decision.why ? `- why: ${decision.why}` : "- why: not captured"].join("\n")),
1610
+ ].join("\n\n");
1611
+ }
1612
+ function buildSessionCoach(input) {
1613
+ const verificationCount = input.signals.filter((signal) => signal.kind === "verification_result").length;
1614
+ const implementationCount = input.signals.filter((signal) => signal.kind === "implementation_update").length;
1615
+ const directionCount = input.signals.filter((signal) => signal.kind === "direction_change").length;
1616
+ const finalSummaryCount = input.signals.filter((signal) => signal.kind === "final_summary").length;
1617
+ const decisionCount = input.decisions.length;
1618
+ const hasVerification = verificationCount > 0;
1619
+ const hasDecision = decisionCount > 0;
1620
+ const hasDirection = directionCount > 0;
1621
+ const qualityScore = Math.max(0, Math.min(100, 35 +
1622
+ Math.min(25, decisionCount * 12) +
1623
+ (hasVerification ? 25 : 0) +
1624
+ (hasDirection ? 10 : 0) -
1625
+ Math.max(0, implementationCount - decisionCount) * 3));
1626
+ const failureMode = implementationCount >= 3 && verificationCount === 0
1627
+ ? `You captured ${implementationCount} implementation updates but no verification_result; run one check and capture it before ending.`
1628
+ : input.signals.length >= 6 && finalSummaryCount === 0
1629
+ ? `You captured ${input.signals.length} signals but no final_summary; close the session with the outcome and remaining risk.`
1630
+ : decisionCount === 0 && directionCount > 0
1631
+ ? "Direction changed, but no decision was captured; promote the clearest direction_change signal."
1632
+ : null;
1633
+ const biggestGap = failureMode ?? (!hasDecision
1634
+ ? "Promote the clearest captured signal into a decision before the trail goes stale."
1635
+ : !hasVerification
1636
+ ? "Capture one verification result so the decision trail records whether the work actually held."
1637
+ : implementationCount > decisionCount * 3
1638
+ ? "There are many implementation updates per decision; collapse the important why into one decision."
1639
+ : "The trail is usable. Keep the next decision tied to a verification result.");
1640
+ return { qualityScore, biggestGap, failureMode };
1641
+ }
1642
+ function renderSessionMarkdown(input) {
1643
+ const counts = input.signals.reduce((accumulator, signal) => {
1644
+ accumulator[signal.kind] = (accumulator[signal.kind] ?? 0) + 1;
1645
+ return accumulator;
1646
+ }, {});
1647
+ const activeDecisions = input.decisions.filter((decision) => decision.status !== "abandoned");
1648
+ const coaching = buildSessionCoach({
1649
+ signals: input.signals.map((signal) => ({ ...signal, filesTouched: [], commandsRun: [] })),
1650
+ decisions: input.decisions,
1651
+ });
1652
+ const lines = [
1653
+ "# Session Review",
1654
+ `Session: ${input.sessionId ?? "none"}`,
1655
+ `Signals: ${input.signals.length}`,
1656
+ `Signal kinds: ${Object.entries(counts).map(([kind, count]) => `${kind} ${count}`).join(", ") || "none"}`,
1657
+ `Active decisions: ${activeDecisions.length}`,
1658
+ `Coaching tip: ${coaching.biggestGap}`,
1659
+ "",
1660
+ "## Signals By Kind",
1661
+ ...Object.entries(counts)
1662
+ .sort(([left], [right]) => left.localeCompare(right))
1663
+ .map(([kind, count]) => `- ${kind}: ${count}`),
1664
+ "",
1665
+ "## Decision Candidates",
1666
+ ...(input.decisionCandidates?.length
1667
+ ? input.decisionCandidates.slice(0, 10).map((candidate) => `- signal ${candidate.signalId}: ${candidate.summary} (${candidate.suggestedStatus})`)
1668
+ : ["- none"]),
1669
+ "",
1670
+ "## Active Decisions",
1671
+ ...(activeDecisions.length
1672
+ ? activeDecisions.map((decision) => `- ${decision.headline} (${decision.status})${decision.why ? ` - ${decision.why}` : ""}`)
1673
+ : ["- none"]),
1674
+ ];
1675
+ return lines.slice(0, 300).join("\n");
1676
+ }
1677
+ function decisionalWeight(signal) {
1678
+ const kindWeight = signal.kind === "direction_change" ? 4 : signal.kind === "verification_result" ? 3 : signal.kind === "implementation_update" ? 2 : 1;
1679
+ const textWeight = /\b(decid|chose|commit|reject|approve|ship|verify|blocked|risk)\b/i.test(signal.summary) ? 2 : 0;
1680
+ return kindWeight + textWeight;
1681
+ }
1682
+ function renderRecap(input) {
1683
+ if (input.format === "standup") {
1684
+ const blockers = input.signals.filter((signal) => /\b(block|fail|error|risk|stuck)\b/i.test(signal.summary));
1685
+ return [
1686
+ "# Standup Recap",
1687
+ "## Yesterday",
1688
+ input.decisions.length ? `- Captured ${input.decisions.length} decisions.` : "- No decisions captured.",
1689
+ "## Today",
1690
+ input.signals.length ? `- Review ${input.signals.length} session signals and promote the strongest one.` : "- Capture the first useful signal.",
1691
+ "## Blockers",
1692
+ ...(blockers.length ? blockers.slice(0, 5).map((signal) => `- ${signal.summary}`) : ["- None captured."]),
1693
+ ].join("\n");
1694
+ }
1695
+ if (input.format === "share") {
1696
+ return [
1697
+ "# Ask The W Session Share",
1698
+ "",
1699
+ `Signals captured: ${input.signals.length}`,
1700
+ `Decisions captured: ${input.decisions.length}`,
1701
+ "",
1702
+ "## Highlights",
1703
+ ...input.signals
1704
+ .slice()
1705
+ .sort((left, right) => decisionalWeight(right) - decisionalWeight(left))
1706
+ .slice(0, 8)
1707
+ .map((signal) => `- ${signal.summary}`),
1708
+ "",
1709
+ "_Captured by Ask The W._",
1710
+ ].join("\n");
1711
+ }
1712
+ return [
1713
+ "# Session Digest",
1714
+ "",
1715
+ ...input.signals
1716
+ .slice()
1717
+ .sort((left, right) => decisionalWeight(right) - decisionalWeight(left))
1718
+ .slice(0, 28)
1719
+ .map((signal, index) => `${index + 1}. [${signal.kind}] ${signal.summary}`),
1720
+ ].join("\n").split("\n").slice(0, 30).join("\n");
1721
+ }
1722
+ function renderSessionFeed(signals) {
1723
+ if (signals.length === 0) {
1724
+ return "# Session\n\nNo local signals captured yet.";
1725
+ }
1726
+ return [
1727
+ "# Session",
1728
+ "",
1729
+ ...signals.map((signal) => [
1730
+ `## ${signal.sequence ?? signal.id}. ${signal.kind}`,
1731
+ signal.summary,
1732
+ `- captured: ${signal.capturedAt}`,
1733
+ signal.filesTouched.length ? `- files: ${signal.filesTouched.join(", ")}` : "- files: none",
1734
+ signal.commandsRun.length ? `- commands: ${signal.commandsRun.join(" | ")}` : "- commands: none",
1735
+ ].join("\n")),
1736
+ ].join("\n\n");
1737
+ }
1738
+ function compactSignal(signal, decision) {
1739
+ return {
1740
+ id: signal.id,
1741
+ kind: signal.kind,
1742
+ summary: signal.summary,
1743
+ files: signal.filesTouched,
1744
+ decisionId: decision?.id ?? null,
1745
+ };
1746
+ }
1747
+ function decisionWithSignals(store, decision) {
1748
+ return {
1749
+ ...decision,
1750
+ contributingSignals: store.listSignalsByIds(decision.sourceSignalIds),
1751
+ };
1752
+ }
1753
+ function signalWithDecision(store, signal) {
1754
+ const decision = store.getDecisionForSignal(signal.id);
1755
+ return {
1756
+ ...signal,
1757
+ decisionId: decision?.id ?? null,
1758
+ decision: decision
1759
+ ? {
1760
+ id: decision.id,
1761
+ headline: decision.headline,
1762
+ status: decision.status,
1763
+ why: decision.why,
1764
+ }
1765
+ : null,
1766
+ };
1767
+ }
1768
+ function candidateFromSignal(signal, linkedDecision) {
1769
+ if (linkedDecision)
1770
+ return null;
1771
+ const text = [signal.summary, ...signal.evidence.map((entry) => {
1772
+ if (entry && typeof entry === "object") {
1773
+ const record = entry;
1774
+ return [record.excerpt, record.diff, record.before, record.after].filter(Boolean).join(" ");
1775
+ }
1776
+ return String(entry ?? "");
1777
+ })].join(" ");
1778
+ const hasDecisionLanguage = /\b(decid(?:e|ed|ing)?|chose|choose|commit(?:ted)?|approved?|reject(?:ed)?|let'?s go with|go with|we will|we're going to|standardize|adopt|defer|drop|keep|remove|replace)\b/i.test(text);
1779
+ if (!hasDecisionLanguage && signal.kind !== "direction_change") {
1780
+ return null;
1781
+ }
1782
+ const because = /\bbecause\b/i.test(text);
1783
+ return {
1784
+ id: `candidate_${signal.id}`,
1785
+ signalId: signal.id,
1786
+ sessionId: signal.sessionId,
1787
+ summary: signal.summary,
1788
+ suggestedStatus: signal.kind === "verification_result" ? "shipped" : "proposed",
1789
+ why: because ? "The signal includes an explicit because/reason clause." : "The signal uses decision language.",
1790
+ files: signal.filesTouched,
1791
+ capturedAt: signal.capturedAt,
1792
+ };
1793
+ }
1794
+ function listDecisionCandidates(input) {
1795
+ const signals = input.store.listSignals({
1796
+ sessionId: input.sessionId ?? undefined,
1797
+ scopeKey: input.scopeKey,
1798
+ cursor: input.cursor,
1799
+ limit: Math.max(1, Math.min(300, input.limit ?? 50)),
1800
+ });
1801
+ const candidates = signals
1802
+ .map((signal) => candidateFromSignal(signal, input.store.getDecisionForSignal(signal.id)))
1803
+ .filter((candidate) => Boolean(candidate));
1804
+ return {
1805
+ candidates,
1806
+ nextCursor: signals.length >= (input.limit ?? 50) ? signals.at(-1)?.capturedAt ?? null : null,
1807
+ };
1808
+ }
1809
+ function normalizedDecisionTerms(text) {
1810
+ const stop = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "keep", "use", "adopt", "remove", "drop", "replace", "defer", "ship", "commit"]);
1811
+ return String(text ?? "")
1812
+ .toLowerCase()
1813
+ .replace(/[^a-z0-9\s-]/g, " ")
1814
+ .split(/\s+/)
1815
+ .filter((term) => term.length >= 4 && !stop.has(term));
1816
+ }
1817
+ function decisionPolarity(text) {
1818
+ if (/\b(remove|drop|abandon|defer|reject|disable|stop|sunset|do not|don't|won't|will not)\b/i.test(text))
1819
+ return "negative";
1820
+ if (/\b(keep|use|adopt|enable|add|ship|commit|standardize|choose|approve|go with)\b/i.test(text))
1821
+ return "positive";
1822
+ return "neutral";
1823
+ }
1824
+ function detectDecisionConflicts(input) {
1825
+ const polarity = decisionPolarity(`${input.decision.headline} ${input.decision.rawContent}`);
1826
+ if (polarity === "neutral")
1827
+ return [];
1828
+ const terms = new Set(normalizedDecisionTerms(`${input.decision.headline} ${input.decision.rawContent}`));
1829
+ if (terms.size === 0)
1830
+ return [];
1831
+ return input.decisions
1832
+ .filter((prior) => prior.id !== input.decision.id)
1833
+ .filter((prior) => !input.decision.scopeKey || prior.scopeKey === input.decision.scopeKey)
1834
+ .map((prior) => {
1835
+ const priorPolarity = decisionPolarity(`${prior.headline} ${prior.rawContent}`);
1836
+ const priorTerms = normalizedDecisionTerms(`${prior.headline} ${prior.rawContent}`);
1837
+ const overlap = priorTerms.filter((term) => terms.has(term));
1838
+ return {
1839
+ prior,
1840
+ priorPolarity,
1841
+ overlap,
1842
+ };
1843
+ })
1844
+ .filter((entry) => entry.priorPolarity !== "neutral" && entry.priorPolarity !== polarity && entry.overlap.length > 0)
1845
+ .slice(0, 3)
1846
+ .map((entry) => ({
1847
+ code: "possible_conflict",
1848
+ message: `This may conflict with "${entry.prior.headline}".`,
1849
+ conflictingDecisionId: entry.prior.id,
1850
+ overlappingTerms: entry.overlap.slice(0, 5),
1851
+ }));
1852
+ }
1853
+ function searchTrail(input) {
1854
+ const terms = String(input.query ?? "")
1855
+ .toLowerCase()
1856
+ .split(/\s+/)
1857
+ .filter(Boolean);
1858
+ const limit = Math.max(1, Math.min(100, input.limit ?? 25));
1859
+ const haystackMatches = (value) => terms.every((term) => value.toLowerCase().includes(term));
1860
+ const signals = input.store
1861
+ .listSignals({ scopeKey: input.scopeKey, sessionId: input.sessionId ?? undefined, cursor: input.cursor, limit: 100000 })
1862
+ .filter((signal) => haystackMatches([
1863
+ signal.summary,
1864
+ signal.kind,
1865
+ signal.filesTouched.join(" "),
1866
+ signal.commandsRun.join(" "),
1867
+ JSON.stringify(signal.evidence),
1868
+ JSON.stringify(signal.metadata),
1869
+ ].join(" ")))
1870
+ .map((signal) => ({
1871
+ type: "signal",
1872
+ id: String(signal.id),
1873
+ createdAt: signal.capturedAt,
1874
+ score: terms.length,
1875
+ result: input.compact ? compactSignal(signal, input.store.getDecisionForSignal(signal.id)) : signalWithDecision(input.store, signal),
1876
+ }));
1877
+ const decisions = input.store
1878
+ .listDecisions({ scopeKey: input.scopeKey, sessionId: input.sessionId ?? undefined, cursor: input.cursor, limit: 100000 })
1879
+ .filter((decision) => haystackMatches([decision.headline, decision.why ?? "", decision.rawContent, decision.files.join(" "), decision.status].join(" ")))
1880
+ .map((decision) => ({
1881
+ type: "decision",
1882
+ id: decision.id,
1883
+ createdAt: decision.createdAt,
1884
+ score: terms.length,
1885
+ result: input.compact
1886
+ ? {
1887
+ id: decision.id,
1888
+ headline: decision.headline,
1889
+ status: decision.status,
1890
+ signalIds: decision.sourceSignalIds,
1891
+ }
1892
+ : decisionWithSignals(input.store, decision),
1893
+ }));
1894
+ const matches = [...signals, ...decisions]
1895
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
1896
+ .slice(0, limit);
1897
+ return {
1898
+ matches,
1899
+ nextCursor: matches.length >= limit ? matches.at(-1)?.createdAt ?? null : null,
1900
+ };
1901
+ }