@askthew/mcp-plugin 0.4.0 → 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 (45) hide show
  1. package/README.md +24 -13
  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 +293 -37
  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 +47 -13
  13. package/dist/index.js +1103 -106
  14. package/dist/index.test.js +609 -6
  15. package/dist/install.d.ts +40 -0
  16. package/dist/install.js +155 -18
  17. package/dist/install.test.js +62 -2
  18. package/dist/lib/auth-pending.d.ts +23 -0
  19. package/dist/lib/auth-pending.js +36 -0
  20. package/dist/lib/cli-actions.d.ts +28 -0
  21. package/dist/lib/cli-actions.js +104 -0
  22. package/dist/lib/free-install-registration.d.ts +27 -0
  23. package/dist/lib/free-install-registration.js +52 -0
  24. package/dist/lib/free-tier-policy.d.ts +5 -1
  25. package/dist/lib/free-tier-policy.js +16 -1
  26. package/dist/lib/local-identity.d.ts +44 -0
  27. package/dist/lib/local-identity.js +81 -0
  28. package/dist/lib/local-store.d.ts +33 -2
  29. package/dist/lib/local-store.js +191 -19
  30. package/dist/lib/paths.d.ts +2 -0
  31. package/dist/lib/paths.js +6 -0
  32. package/dist/lib/telemetry.js +28 -2
  33. package/dist/lib/timeline-insights.d.ts +23 -0
  34. package/dist/lib/timeline-insights.js +115 -0
  35. package/dist/lib/upgrade-nudge.d.ts +1 -1
  36. package/dist/lib/upgrade-nudge.js +8 -1
  37. package/dist/local-identity.test.d.ts +1 -0
  38. package/dist/local-identity.test.js +29 -0
  39. package/dist/local-store.test.js +34 -0
  40. package/dist/scope.d.ts +1 -1
  41. package/dist/scope.js +56 -2
  42. package/dist/scope.test.js +17 -0
  43. package/dist/timeline-insights.test.d.ts +1 -0
  44. package/dist/timeline-insights.test.js +85 -0
  45. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3,10 +3,20 @@ import { z } from "zod";
3
3
  import { resolvePluginScope } from "./scope.js";
4
4
  import { resolveMcpMode } from "./lib/free-tier-policy.js";
5
5
  import { LocalStore } from "./lib/local-store.js";
6
- import { analyzeLocalPatterns } from "./lib/tip-engine.js";
7
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";
8
9
  import { paidDescription, paidFeatureNudge, toolJson } from "./lib/upgrade-nudge.js";
10
+ import { configPath, readJsonFile } from "./lib/paths.js";
9
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
+ });
10
20
  const sessionSignalKindSchema = z.enum([
11
21
  "setup_complete",
12
22
  "session_checkpoint",
@@ -21,10 +31,7 @@ export const codingSessionSignalSchema = z.object({
21
31
  kind: sessionSignalKindSchema,
22
32
  summary: z.string().min(1).max(2000),
23
33
  evidence: z
24
- .array(z.object({
25
- role: evidenceRoleSchema,
26
- excerpt: z.string().min(1).max(500),
27
- }))
34
+ .array(evidenceEntrySchema)
28
35
  .default([]),
29
36
  filesTouched: z.array(z.string().min(1).max(500)).default([]),
30
37
  commandsRun: z.array(z.string().min(1).max(500)).default([]),
@@ -53,7 +60,9 @@ const REDACTION_PATTERNS = [
53
60
  { name: "twilio_key", pattern: /\bSK[0-9a-fA-F]{32}\b/g },
54
61
  { name: "slack_token", pattern: /\bxox[baprs]-[0-9A-Za-z\-]{10,}\b/g },
55
62
  { 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 },
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 },
57
66
  { name: "anthropic_key", pattern: /\bsk-ant-[A-Za-z0-9\-_]{32,}\b/g },
58
67
  { name: "github_pat_classic", pattern: /\bghp_[A-Za-z0-9]{36}\b/g },
59
68
  { name: "github_pat_fine", pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
@@ -135,6 +144,12 @@ const SENSITIVE_PATH_SEGMENTS = new Set([
135
144
  ]);
136
145
  const ENTROPY_THRESHOLD = 4.5;
137
146
  const ENTROPY_TOKEN_PATTERN = /[A-Za-z0-9+/=_\-]{20,}/g;
147
+ export function loadAskTheWConfig(env = process.env) {
148
+ return readJsonFile(configPath(env)) ?? {};
149
+ }
150
+ function isRedactionEnabled() {
151
+ return loadAskTheWConfig().redaction?.enabled !== false;
152
+ }
138
153
  function sanitizeUrl(raw) {
139
154
  try {
140
155
  const url = new URL(raw);
@@ -235,6 +250,9 @@ function redactMetadata(value, key) {
235
250
  }
236
251
  export function redactProvenanceSignal(input) {
237
252
  const parsed = provenanceSignalSchema.parse(input);
253
+ if (!isRedactionEnabled()) {
254
+ return parsed;
255
+ }
238
256
  return {
239
257
  ...parsed,
240
258
  decision: redactRawSignalText(parsed.decision),
@@ -252,12 +270,18 @@ export function redactProvenanceSignal(input) {
252
270
  }
253
271
  export function redactCodingSessionSignal(input) {
254
272
  const parsed = codingSessionSignalSchema.parse(input);
273
+ if (!isRedactionEnabled()) {
274
+ return parsed;
275
+ }
255
276
  return {
256
277
  ...parsed,
257
278
  summary: redactRawSignalText(parsed.summary),
258
279
  evidence: parsed.evidence.map((entry) => ({
259
280
  ...entry,
260
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,
261
285
  })),
262
286
  filesTouched: parsed.filesTouched.map((filePath) => sanitizeFilePath(filePath)),
263
287
  commandsRun: parsed.commandsRun.map((command) => redactOperationalContext(command)),
@@ -278,6 +302,32 @@ function normalizeClientId(value) {
278
302
  export function normalizeInstallTokenInput(token) {
279
303
  return String(token ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
280
304
  }
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
+ }
281
331
  function routeWithQuery(route, params) {
282
332
  const searchParams = new URLSearchParams();
283
333
  for (const [key, value] of Object.entries(params)) {
@@ -334,6 +384,7 @@ async function postToServer(route, payload, options, request) {
334
384
  method,
335
385
  headers: {
336
386
  ...(method === "GET" ? {} : { "Content-Type": "application/json" }),
387
+ ...(request?.idempotencyKey ? { "Idempotency-Key": request.idempotencyKey } : {}),
337
388
  ...(installToken
338
389
  ? { Authorization: `Bearer ${installToken}` }
339
390
  : apiKey
@@ -343,18 +394,44 @@ async function postToServer(route, payload, options, request) {
343
394
  ...(method === "GET" ? {} : { body: JSON.stringify(bodyPayload) }),
344
395
  }).catch(() => null);
345
396
  if (!response) {
346
- return {
347
- ok: false,
348
- error: "Ask The W server could not be reached.",
349
- };
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
+ });
350
403
  }
351
404
  const body = await response.json().catch(() => null);
352
405
  if (!response.ok) {
353
- return {
354
- ok: false,
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.",
355
433
  status: response.status,
356
- ...(body && typeof body === "object" ? body : { error: "Ask The W request failed." }),
357
- };
434
+ });
358
435
  }
359
436
  return body;
360
437
  }
@@ -367,6 +444,7 @@ function runtimeMetadata(options) {
367
444
  return {
368
445
  repository: scope.repoName,
369
446
  repo_name: scope.repoName,
447
+ scope_key: localScopeKey(),
370
448
  ...(scope.repoRoot ? { repo_root: sanitizeFilePath(scope.repoRoot) } : {}),
371
449
  ...(scope.appPath ? { app_path: sanitizeFilePath(scope.appPath) } : {}),
372
450
  ...(scope.serviceName ? { service_name: scope.serviceName } : {}),
@@ -376,6 +454,14 @@ function runtimeMetadata(options) {
376
454
  ...extraMetadata,
377
455
  };
378
456
  }
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
+ }
379
465
  function startupSessionId(options) {
380
466
  const scope = resolvePluginScope(process.cwd());
381
467
  const { clientId } = credentials(options?.credentials);
@@ -387,6 +473,20 @@ function startupSessionId(options) {
387
473
  .replace(/[^a-zA-Z0-9:_-]+/g, "_")
388
474
  .slice(0, 240);
389
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
+ }
390
490
  async function sendStartupHeartbeat(options) {
391
491
  if (!hasServerIdentity(options?.credentials)) {
392
492
  return;
@@ -421,7 +521,7 @@ async function sendStartupSetupSignal(options) {
421
521
  sessionId: startupSessionId(options),
422
522
  sequence: 0,
423
523
  kind: "setup_complete",
424
- summary: "Ask The W MCP server started for this coding-agent session.",
524
+ summary: "Ask The W plugin server started for this coding-agent session.",
425
525
  evidence: [],
426
526
  filesTouched: [],
427
527
  commandsRun: [],
@@ -441,20 +541,24 @@ async function sendStartupSignals(options) {
441
541
  await sendStartupSetupSignal(options).catch(() => null);
442
542
  }
443
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
+ }
444
552
  const resolvedMode = resolveMcpMode();
445
553
  const optionInstallToken = normalizeInstallTokenInput(options.credentials?.installToken);
446
554
  const mode = optionInstallToken
447
555
  ? { mode: "paid", installToken: optionInstallToken, reason: "options_install_token" }
448
556
  : resolvedMode;
449
557
  const localStore = mode.mode === "paid" ? null : LocalStore.open();
450
- if (localStore) {
558
+ if (localStore && mode.mode === "free" && mode.cliCredentials) {
451
559
  void flushTelemetryOutbox({
452
560
  store: localStore,
453
- credentials: mode.cliCredentials ?? {
454
- userId: "unauthenticated",
455
- cliToken: "",
456
- cliTokenId: "none",
457
- },
561
+ credentials: mode.cliCredentials,
458
562
  apiUrl: options.apiBaseUrl,
459
563
  fetchImpl: options.fetchImpl,
460
564
  }).catch(() => null);
@@ -463,11 +567,11 @@ export function createAskTheWMcpServer(options = {}) {
463
567
  name: "Ask The W Coding Agent Connector",
464
568
  version: "0.4.0",
465
569
  });
466
- if (options.sendStartupHeartbeat !== false) {
570
+ if (options.sendStartupHeartbeat !== false && mode.mode === "paid") {
467
571
  void sendStartupSignals(options);
468
572
  }
469
- const apiToolResponse = async (route, payload = {}, method = "GET") => {
470
- const upstream = await postToServer(route, payload, options, { method });
573
+ const apiToolResponse = async (route, payload = {}, method = "GET", request) => {
574
+ const upstream = await postToServer(route, payload, options, { method, idempotencyKey: request?.idempotencyKey });
471
575
  return {
472
576
  content: [
473
577
  {
@@ -478,13 +582,71 @@ export function createAskTheWMcpServer(options = {}) {
478
582
  };
479
583
  };
480
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;
481
638
  const requireFreeIdentity = () => {
482
- if (mode.mode === "unauthenticated") {
483
- return localResponse({
484
- ok: false,
639
+ if (mode.mode === "unauthenticated" || mode.mode === "free_pending_auth") {
640
+ const loginCommand = loginCommandHint();
641
+ return localToolError({
485
642
  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",
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
+ },
488
650
  });
489
651
  }
490
652
  return null;
@@ -495,14 +657,13 @@ export function createAskTheWMcpServer(options = {}) {
495
657
  kind: sessionSignalKindSchema,
496
658
  summary: z.string().min(1).max(2000),
497
659
  evidence: z
498
- .array(z.object({
499
- role: evidenceRoleSchema,
500
- excerpt: z.string().min(1).max(500),
501
- }))
660
+ .array(evidenceEntrySchema)
502
661
  .default([]),
503
662
  filesTouched: z.array(z.string().min(1).max(500)).default([]),
504
663
  commandsRun: z.array(z.string().min(1).max(500)).default([]),
505
664
  metadata: z.record(z.string(), z.unknown()).default({}),
665
+ idempotencyKey: idempotencyKeySchema,
666
+ echo: echoSchema,
506
667
  }, async (payload) => {
507
668
  const sessionSignal = redactCodingSessionSignal({
508
669
  ...payload,
@@ -511,6 +672,7 @@ export function createAskTheWMcpServer(options = {}) {
511
672
  ...(payload.metadata ?? {}),
512
673
  },
513
674
  });
675
+ const scopeKey = currentScopeKey();
514
676
  if (mode.mode !== "paid" && localStore) {
515
677
  const loginRequired = requireFreeIdentity();
516
678
  if (loginRequired) {
@@ -525,7 +687,13 @@ export function createAskTheWMcpServer(options = {}) {
525
687
  filesTouched: sessionSignal.filesTouched,
526
688
  commandsRun: sessionSignal.commandsRun,
527
689
  metadata: sessionSignal.metadata,
690
+ scopeKey,
528
691
  });
692
+ const sessionSignalCount = localStore.listSignals({
693
+ sessionId: signal.sessionId,
694
+ scopeKey,
695
+ limit: 100000,
696
+ }).length;
529
697
  if (sessionSignal.kind === "final_summary" && mode.cliCredentials) {
530
698
  localStore.enqueueTelemetry(buildTelemetryPayload({
531
699
  store: localStore,
@@ -539,6 +707,25 @@ export function createAskTheWMcpServer(options = {}) {
539
707
  fetchImpl: options.fetchImpl,
540
708
  }).catch(() => null);
541
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
+ }
542
729
  return localResponse({
543
730
  ok: true,
544
731
  tier: "free",
@@ -548,9 +735,35 @@ export function createAskTheWMcpServer(options = {}) {
548
735
  : "Captured locally in SQLite. Aggregate telemetry only may flush on final_summary unless opted out.",
549
736
  });
550
737
  }
551
- const upstream = await postToServer("/api/ingest/mcp", {
738
+ const upstream = await postToServer(payload.echo === "full" ? "/api/ingest/mcp" : withResponseShape("/api/ingest/mcp"), {
552
739
  sessionSignal,
553
- }, options);
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
+ }
554
767
  return {
555
768
  content: [
556
769
  {
@@ -568,20 +781,46 @@ export function createAskTheWMcpServer(options = {}) {
568
781
  server.tool("list_decisions", {
569
782
  limit: z.number().int().positive().max(300).optional(),
570
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,
571
789
  }, async (payload) => {
572
790
  if (mode.mode !== "paid" && localStore) {
573
791
  const loginRequired = requireFreeIdentity();
574
792
  if (loginRequired)
575
793
  return loginRequired;
576
- return localResponse({
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({
577
803
  ok: true,
578
804
  tier: "free",
579
- decisions: localStore.listDecisions({ limit: payload.limit ?? 50 }),
580
- });
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);
581
815
  }
582
816
  return apiToolResponse(routeWithQuery("/api/decisions", {
583
- limit: payload.limit,
817
+ limit: payload.limit ?? 5,
584
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,
585
824
  }));
586
825
  });
587
826
  server.tool("get_decision", {
@@ -592,29 +831,79 @@ export function createAskTheWMcpServer(options = {}) {
592
831
  if (loginRequired)
593
832
  return loginRequired;
594
833
  const decision = localStore.getDecision(payload.id);
595
- return localResponse(decision ? { ok: true, tier: "free", decision } : { ok: false, code: "not_found" });
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
+ });
596
842
  }
597
843
  return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`);
598
844
  });
599
845
  server.tool("create_decision", {
600
846
  content: z.string().min(1),
847
+ idempotencyKey: idempotencyKeySchema,
848
+ echo: echoSchema,
601
849
  }, async (payload) => {
602
850
  if (mode.mode !== "paid" && localStore) {
603
851
  const loginRequired = requireFreeIdentity();
604
852
  if (loginRequired)
605
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
+ }
606
885
  return localResponse({
607
886
  ok: true,
608
- tier: "free",
609
- decision: localStore.createDecision({
610
- rawContent: payload.content,
611
- sessionId: localStore.mostRecentSessionId(),
612
- }),
887
+ ...(payload.echo === "summary"
888
+ ? { id: decision.id, sequence: localStore.stats().decisions, headline: decision.headline, warnings }
889
+ : { tier: "free", decision: decisionWithSignals(localStore, decision), warnings }),
613
890
  });
614
891
  }
615
- return apiToolResponse("/api/decisions", {
892
+ const upstream = await postToServer(payload.echo === "full" ? "/api/decisions" : withResponseShape("/api/decisions"), {
616
893
  content: payload.content,
617
- }, "POST");
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);
618
907
  });
619
908
  server.tool("update_decision", {
620
909
  id: z.string().min(1),
@@ -623,6 +912,8 @@ export function createAskTheWMcpServer(options = {}) {
623
912
  status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
624
913
  alignment: z.enum(["aligned", "orthogonal", "conflicts", "ambiguous"]).optional(),
625
914
  outcomeId: z.string().min(1).optional(),
915
+ idempotencyKey: idempotencyKeySchema,
916
+ echo: echoSchema,
626
917
  }, async (payload) => {
627
918
  if (mode.mode !== "paid" && localStore) {
628
919
  const loginRequired = requireFreeIdentity();
@@ -634,40 +925,76 @@ export function createAskTheWMcpServer(options = {}) {
634
925
  ...(payload.status ? { status: payload.status } : {}),
635
926
  ...(payload.alignment !== undefined ? { alignment: payload.alignment } : {}),
636
927
  });
637
- return localResponse(decision ? { ok: true, tier: "free", decision } : { ok: false, code: "not_found" });
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 });
638
946
  }
639
- return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`, {
947
+ const upstream = await postToServer(payload.echo === "full"
948
+ ? `/api/decisions/${encodeURIComponent(payload.id)}`
949
+ : withResponseShape(`/api/decisions/${encodeURIComponent(payload.id)}`), {
640
950
  headline: payload.headline,
641
951
  why: payload.why,
642
952
  status: payload.status,
643
953
  alignment: payload.alignment,
644
954
  outcomeId: payload.outcomeId,
645
- }, "PATCH");
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);
646
965
  });
647
966
  server.tool("delete_decision", {
648
967
  id: z.string().min(1),
649
968
  confirmText: z.string().min(1),
969
+ idempotencyKey: idempotencyKeySchema,
650
970
  }, async (payload) => {
651
971
  if (mode.mode !== "paid" && localStore) {
652
972
  const loginRequired = requireFreeIdentity();
653
973
  if (loginRequired)
654
974
  return loginRequired;
655
975
  if (payload.confirmText !== payload.id) {
656
- return localResponse({ ok: false, code: "confirmation_required", message: "confirmText must match the decision 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
+ });
657
982
  }
658
983
  return localResponse({ ok: localStore.deleteDecision(payload.id), tier: "free" });
659
984
  }
660
985
  return apiToolResponse(`/api/decisions/${encodeURIComponent(payload.id)}`, {
661
986
  confirmText: payload.confirmText,
662
- }, "DELETE");
987
+ }, "DELETE", { idempotencyKey: payload.idempotencyKey });
663
988
  });
664
989
  server.tool("list_outcomes", paidDescription("List outcomes from your workspace.", mode.mode), {
665
990
  limit: z.number().int().positive().max(300).optional(),
991
+ cursor: cursorSchema,
666
992
  }, async (payload) => {
667
993
  if (mode.mode === "free")
668
994
  return localResponse(paidFeatureNudge("list_outcomes"));
669
995
  return apiToolResponse(routeWithQuery("/api/outcomes", {
670
996
  limit: payload.limit,
997
+ cursor: payload.cursor,
671
998
  }));
672
999
  });
673
1000
  server.tool("get_outcome", paidDescription("Get outcome detail from your workspace.", mode.mode), {
@@ -677,14 +1004,20 @@ export function createAskTheWMcpServer(options = {}) {
677
1004
  : apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`));
678
1005
  server.tool("list_outcome_signals", paidDescription("List signals linked to an outcome.", mode.mode), {
679
1006
  id: z.string().min(1),
1007
+ limit: z.number().int().positive().max(300).optional(),
1008
+ cursor: cursorSchema,
680
1009
  }, async (payload) => mode.mode === "free"
681
1010
  ? localResponse(paidFeatureNudge("list_outcome_signals"))
682
- : apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}/signals`));
1011
+ : apiToolResponse(routeWithQuery(`/api/outcomes/${encodeURIComponent(payload.id)}/signals`, {
1012
+ limit: payload.limit,
1013
+ cursor: payload.cursor,
1014
+ })));
683
1015
  server.tool("create_outcome", paidDescription("Create a new outcome.", mode.mode), {
684
1016
  name: z.string().min(1),
685
1017
  summary: z.string().optional(),
686
1018
  causalHypothesis: z.string().optional(),
687
1019
  suggestedAction: z.string().optional(),
1020
+ idempotencyKey: idempotencyKeySchema,
688
1021
  }, async (payload) => {
689
1022
  if (mode.mode === "free")
690
1023
  return localResponse(paidFeatureNudge("create_outcome"));
@@ -693,7 +1026,7 @@ export function createAskTheWMcpServer(options = {}) {
693
1026
  summary: payload.summary,
694
1027
  causalHypothesis: payload.causalHypothesis,
695
1028
  suggestedAction: payload.suggestedAction,
696
- }, "POST");
1029
+ }, "POST", { idempotencyKey: payload.idempotencyKey });
697
1030
  });
698
1031
  server.tool("update_outcome", paidDescription("Update an outcome.", mode.mode), {
699
1032
  id: z.string().min(1),
@@ -702,26 +1035,40 @@ export function createAskTheWMcpServer(options = {}) {
702
1035
  causalHypothesis: z.string().optional(),
703
1036
  suggestedAction: z.string().optional(),
704
1037
  status: z.enum(["active", "achieved", "abandoned", "archived"]).optional(),
1038
+ idempotencyKey: idempotencyKeySchema,
1039
+ echo: echoSchema,
705
1040
  }, async (payload) => {
706
1041
  if (mode.mode === "free")
707
1042
  return localResponse(paidFeatureNudge("update_outcome"));
708
- return apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`, {
1043
+ const upstream = await postToServer(payload.echo === "full"
1044
+ ? `/api/outcomes/${encodeURIComponent(payload.id)}`
1045
+ : withResponseShape(`/api/outcomes/${encodeURIComponent(payload.id)}`), {
709
1046
  name: payload.name,
710
1047
  summary: payload.summary,
711
1048
  causalHypothesis: payload.causalHypothesis,
712
1049
  suggestedAction: payload.suggestedAction,
713
1050
  status: payload.status,
714
- }, "PATCH");
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);
715
1061
  });
716
1062
  server.tool("delete_outcome", paidDescription("Delete an outcome.", mode.mode), {
717
1063
  id: z.string().min(1),
718
1064
  confirmText: z.string().min(1),
1065
+ idempotencyKey: idempotencyKeySchema,
719
1066
  }, async (payload) => {
720
1067
  if (mode.mode === "free")
721
1068
  return localResponse(paidFeatureNudge("delete_outcome"));
722
1069
  return apiToolResponse(`/api/outcomes/${encodeURIComponent(payload.id)}`, {
723
1070
  confirmText: payload.confirmText,
724
- }, "DELETE");
1071
+ }, "DELETE", { idempotencyKey: payload.idempotencyKey });
725
1072
  });
726
1073
  server.tool("get_north_star", paidDescription("Read the workspace north-star metric.", mode.mode), {}, async () => mode.mode === "free"
727
1074
  ? localResponse(paidFeatureNudge("get_north_star"))
@@ -731,6 +1078,7 @@ export function createAskTheWMcpServer(options = {}) {
731
1078
  current: z.string().min(1),
732
1079
  target: z.string().min(1),
733
1080
  reason: z.string().min(1),
1081
+ idempotencyKey: idempotencyKeySchema,
734
1082
  }, async (payload) => {
735
1083
  if (mode.mode === "free")
736
1084
  return localResponse(paidFeatureNudge("update_north_star"));
@@ -739,25 +1087,85 @@ export function createAskTheWMcpServer(options = {}) {
739
1087
  current: payload.current,
740
1088
  target: payload.target,
741
1089
  reason: payload.reason,
742
- }, "POST");
1090
+ }, "POST", { idempotencyKey: payload.idempotencyKey });
743
1091
  });
744
1092
  server.tool("list_signals", {
745
1093
  limit: z.number().int().positive().max(300).optional(),
746
1094
  cursor: z.string().optional(),
1095
+ sessionId: z.string().optional(),
1096
+ since: z.string().optional(),
1097
+ compact: z.boolean().optional(),
1098
+ max_chars: maxCharsSchema,
747
1099
  }, async (payload) => {
748
1100
  if (mode.mode !== "paid" && localStore) {
749
1101
  const loginRequired = requireFreeIdentity();
750
1102
  if (loginRequired)
751
1103
  return loginRequired;
752
- return localResponse({
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({
753
1112
  ok: true,
754
1113
  tier: "free",
755
- signals: localStore.listSignals({ limit: payload.limit ?? 300 }),
756
- });
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);
757
1119
  }
758
1120
  return apiToolResponse(routeWithQuery("/api/signals", {
759
- limit: payload.limit,
1121
+ limit: payload.limit ?? 10,
760
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,
761
1169
  }));
762
1170
  });
763
1171
  server.tool("get_signal", {
@@ -768,25 +1176,46 @@ export function createAskTheWMcpServer(options = {}) {
768
1176
  if (loginRequired)
769
1177
  return loginRequired;
770
1178
  const signal = localStore.getSignal(Number(payload.id));
771
- return localResponse(signal ? { ok: true, tier: "free", signal } : { ok: false, code: "not_found" });
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
+ });
772
1187
  }
773
1188
  return apiToolResponse(`/api/signals/${encodeURIComponent(payload.id)}`);
774
1189
  });
775
- server.tool("review_decisions", {
1190
+ server.tool("review_decisions", "Review captured decisions. Use for natural prompts like: What did I decide yesterday?", {
776
1191
  since: z.string().optional(),
777
1192
  status: z.enum(["proposed", "committed", "shipped", "abandoned"]).optional(),
778
1193
  format: z.enum(["markdown", "json"]).default("markdown"),
779
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,
780
1199
  }, async (payload) => {
781
1200
  if (mode.mode === "paid") {
782
1201
  return apiToolResponse(routeWithQuery("/api/decisions", {
783
1202
  since: payload.since,
784
1203
  status: payload.status,
785
1204
  limit: payload.limit,
1205
+ cursor: payload.cursor,
1206
+ sessionId: payload.sessionId,
1207
+ compact: payload.compact,
1208
+ max_chars: payload.max_chars,
786
1209
  }));
787
1210
  }
788
- if (!localStore)
789
- return localResponse({ ok: false, code: "local_store_unavailable" });
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
+ }
790
1219
  const loginRequired = requireFreeIdentity();
791
1220
  if (loginRequired)
792
1221
  return loginRequired;
@@ -794,86 +1223,380 @@ export function createAskTheWMcpServer(options = {}) {
794
1223
  since: payload.since,
795
1224
  status: payload.status,
796
1225
  limit: payload.limit,
1226
+ cursor: payload.cursor,
1227
+ sessionId: payload.sessionId,
1228
+ scopeKey: currentScopeKey(),
797
1229
  });
798
- return localResponse({
1230
+ return budgetedLocalResponse({
799
1231
  ok: true,
800
1232
  tier: "free",
801
1233
  format: payload.format,
802
1234
  rendered: renderDecisionDigest(decisions),
803
- 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)),
804
1243
  count: decisions.length,
1244
+ nextCursor: decisions.length >= payload.limit ? decisions.at(-1)?.createdAt ?? null : null,
805
1245
  copyHint: "Copy this output to back up your decisions - `export_decisions` is a paid feature.",
806
- });
1246
+ }, payload.max_chars);
807
1247
  });
808
- server.tool("review_session", {
1248
+ server.tool("review_session", "Review the current session trail. Use for natural prompts like: Show me my session trail.", {
809
1249
  sessionId: z.string().optional(),
810
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,
811
1255
  }, async (payload) => {
812
1256
  if (!localStore || mode.mode === "paid") {
813
- return apiToolResponse(routeWithQuery("/api/signals", { sessionId: payload.sessionId }));
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
+ }));
814
1264
  }
815
1265
  const loginRequired = requireFreeIdentity();
816
1266
  if (loginRequired)
817
1267
  return loginRequired;
818
- const sessionId = payload.sessionId ?? localStore.mostRecentSessionId();
819
- const signals = sessionId ? localStore.listSignals({ sessionId, limit: 100000 }) : [];
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;
820
1295
  const counts = signals.reduce((accumulator, signal) => {
821
1296
  accumulator[signal.kind] = (accumulator[signal.kind] ?? 0) + 1;
822
1297
  return accumulator;
823
1298
  }, {});
824
- return localResponse({
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({
825
1325
  ok: true,
826
1326
  tier: "free",
827
1327
  sessionId,
828
- format: payload.format,
829
- rendered: renderSessionFeed(signals),
830
- signals,
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
+ : {}),
831
1333
  counts: {
832
- totalSignals: signals.length,
833
- byKind: counts,
1334
+ totalSignals: allSignals.length,
1335
+ byKind: Object.keys(allCounts).length ? allCounts : counts,
834
1336
  },
835
- });
1337
+ }, payload.max_chars);
836
1338
  });
837
- server.tool("analyze_session", {
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"),
838
1341
  sessionId: z.string().optional(),
839
- window: z.enum(["session", "day", "week"]).default("session"),
1342
+ compact: z.boolean().optional(),
1343
+ max_chars: maxCharsSchema,
840
1344
  }, async (payload) => {
841
- if (!localStore || mode.mode === "paid") {
842
- return localResponse(paidFeatureNudge("analyze_session"));
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"));
843
1372
  }
1373
+ if (!localStore || mode.mode === "paid")
1374
+ return localResponse(paidFeatureNudge("coach"));
844
1375
  const loginRequired = requireFreeIdentity();
845
1376
  if (loginRequired)
846
1377
  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;
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
+ });
853
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({
854
1488
  ok: true,
855
1489
  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",
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,
870
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);
871
1524
  });
872
1525
  server.tool("export_decisions", paidDescription("Export decisions from your workspace.", mode.mode), {
873
1526
  format: z.enum(["json", "markdown", "jsonl"]).default("json"),
874
- }, async () => mode.mode === "free"
1527
+ cursor: cursorSchema,
1528
+ limit: z.number().int().positive().max(300).optional(),
1529
+ max_chars: maxCharsSchema,
1530
+ }, async (payload) => mode.mode === "free"
875
1531
  ? localResponse(paidFeatureNudge("export_decisions"))
876
- : apiToolResponse("/api/export/timeline"));
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
+ });
877
1600
  return server;
878
1601
  }
879
1602
  function renderDecisionDigest(decisions) {
@@ -886,6 +1609,116 @@ function renderDecisionDigest(decisions) {
886
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")),
887
1610
  ].join("\n\n");
888
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
+ }
889
1722
  function renderSessionFeed(signals) {
890
1723
  if (signals.length === 0) {
891
1724
  return "# Session\n\nNo local signals captured yet.";
@@ -902,3 +1735,167 @@ function renderSessionFeed(signals) {
902
1735
  ].join("\n")),
903
1736
  ].join("\n\n");
904
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
+ }