@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
@@ -1,6 +1,8 @@
1
1
  import crypto from "node:crypto";
2
2
  import { resolvePluginScope } from "../scope.js";
3
3
  import { isTelemetryOptedOut } from "./free-tier-policy.js";
4
+ import { tryRegisterFreeInstall } from "./free-install-registration.js";
5
+ import { signLocalIdentityPayload } from "./local-identity.js";
4
6
  export function buildTelemetryPayload(input) {
5
7
  const now = input.now ?? new Date();
6
8
  const sessionId = input.sessionId ?? input.store.mostRecentSessionId() ?? "unknown";
@@ -29,6 +31,13 @@ export function buildTelemetryPayload(input) {
29
31
  platform: `${process.platform}-${process.arch}`,
30
32
  node: process.version.replace(/^v/, ""),
31
33
  },
34
+ identity: input.credentials.identityKind === "local_install"
35
+ ? {
36
+ kind: "local_install",
37
+ installId: input.credentials.installId,
38
+ emailClaimed: Boolean(input.credentials.email),
39
+ }
40
+ : { kind: "legacy_token" },
32
41
  });
33
42
  }
34
43
  export async function flushTelemetryOutbox(input) {
@@ -37,15 +46,32 @@ export async function flushTelemetryOutbox(input) {
37
46
  }
38
47
  const fetcher = input.fetchImpl ?? fetch;
39
48
  const apiUrl = (input.apiUrl ?? input.credentials.apiUrl ?? process.env.ASKTHEW_API_URL ?? "https://app.askthew.com").replace(/\/$/, "");
49
+ if (input.credentials.identityKind === "local_install" && input.credentials.localIdentity) {
50
+ await tryRegisterFreeInstall({
51
+ identity: input.credentials.localIdentity,
52
+ deviceLabel: "askthew-mcp",
53
+ options: { apiUrl, fetchImpl: fetcher },
54
+ });
55
+ }
40
56
  let sent = 0;
41
57
  for (const row of input.store.listTelemetryOutbox({ undeliveredOnly: true, limit: 20 })) {
58
+ const body = JSON.stringify(row.payload);
59
+ const signed = input.credentials.identityKind === "local_install" && input.credentials.localIdentity
60
+ ? signLocalIdentityPayload({ identity: input.credentials.localIdentity, body })
61
+ : null;
42
62
  const response = await fetcher(`${apiUrl}/api/cli/v1/telemetry`, {
43
63
  method: "POST",
44
64
  headers: {
45
65
  "Content-Type": "application/json",
46
- Authorization: `Bearer ${input.credentials.cliToken}`,
66
+ ...(signed
67
+ ? {
68
+ "X-AskTheW-Install-Id": input.credentials.localIdentity.installId,
69
+ "X-AskTheW-Timestamp": signed.timestamp,
70
+ "X-AskTheW-Signature": signed.signature,
71
+ }
72
+ : { Authorization: `Bearer ${input.credentials.cliToken}` }),
47
73
  },
48
- body: JSON.stringify(row.payload),
74
+ body,
49
75
  }).catch(() => null);
50
76
  const delivered = Boolean(response?.ok);
51
77
  input.store.markTelemetryAttempt(row.id, delivered);
@@ -0,0 +1,23 @@
1
+ import type { LocalDecision, LocalSignal } from "./local-store.js";
2
+ export interface TimelinePoint {
3
+ x: string;
4
+ signalCount: number;
5
+ decisionCount: number;
6
+ startedAt?: string;
7
+ endedAt?: string;
8
+ durationMinutes?: number;
9
+ }
10
+ export interface TimelineInsight {
11
+ id: string;
12
+ type: "trend" | "recommendation";
13
+ title: string;
14
+ body: string;
15
+ }
16
+ export declare function buildLocalTimeline(input: {
17
+ scope: "day" | "month" | "session";
18
+ signals: LocalSignal[];
19
+ decisions: LocalDecision[];
20
+ limit?: number;
21
+ }): TimelinePoint[];
22
+ export declare function buildTimelineInsights(points: TimelinePoint[]): TimelineInsight[];
23
+ export declare function renderTimelineMarkdown(points: TimelinePoint[], scope: "day" | "month" | "session"): string;
@@ -0,0 +1,115 @@
1
+ export function buildLocalTimeline(input) {
2
+ if (input.scope === "session")
3
+ return buildSessionTimeline(input);
4
+ const buckets = new Map();
5
+ for (const signal of input.signals) {
6
+ const x = input.scope === "month" ? signal.capturedAt.slice(0, 7) : signal.capturedAt.slice(0, 10);
7
+ const point = buckets.get(x) ?? { x, signalCount: 0, decisionCount: 0 };
8
+ point.signalCount += 1;
9
+ buckets.set(x, point);
10
+ }
11
+ for (const decision of input.decisions) {
12
+ const x = input.scope === "month" ? decision.createdAt.slice(0, 7) : decision.createdAt.slice(0, 10);
13
+ const point = buckets.get(x) ?? { x, signalCount: 0, decisionCount: 0 };
14
+ point.decisionCount += 1;
15
+ buckets.set(x, point);
16
+ }
17
+ return [...buckets.values()].sort((left, right) => left.x.localeCompare(right.x));
18
+ }
19
+ export function buildTimelineInsights(points) {
20
+ const totalSignals = points.reduce((sum, point) => sum + point.signalCount, 0);
21
+ const totalDecisions = points.reduce((sum, point) => sum + point.decisionCount, 0);
22
+ const insights = [
23
+ {
24
+ id: "timeline.activity_summary",
25
+ type: "trend",
26
+ title: "Timeline activity summarized",
27
+ body: `This period includes ${totalSignals} signals and ${totalDecisions} decisions.`,
28
+ },
29
+ ];
30
+ if (totalSignals >= 20 && (totalDecisions === 0 || totalSignals / Math.max(1, totalDecisions) >= 8)) {
31
+ insights.push({
32
+ id: "timeline.signal_to_decision_imbalance",
33
+ type: "recommendation",
34
+ title: "Signals are far ahead of decisions",
35
+ body: `${totalSignals} signals produced ${totalDecisions} decisions. Promote the strongest evidence into decisions before the trail gets noisy.`,
36
+ });
37
+ }
38
+ else {
39
+ insights.push({
40
+ id: "timeline.review_next_bucket",
41
+ type: "recommendation",
42
+ title: "Review the next decision bucket",
43
+ body: totalDecisions > 0
44
+ ? "Open the busiest bucket and check whether the decisions still describe the current plan."
45
+ : "Capture one decision for the most important change before the next review.",
46
+ });
47
+ }
48
+ const maxDecision = Math.max(0, ...points.map((point) => point.decisionCount));
49
+ const averageDecision = points.reduce((sum, point) => sum + point.decisionCount, 0) / Math.max(1, points.length);
50
+ if (maxDecision >= 4 && maxDecision >= averageDecision * 2.5) {
51
+ insights.push({
52
+ id: "timeline.decision_spike",
53
+ type: "trend",
54
+ title: "Decision spike detected",
55
+ body: `One bucket captured ${maxDecision} decisions, well above the period average of ${averageDecision.toFixed(1)}.`,
56
+ });
57
+ }
58
+ return insights.slice(0, 6);
59
+ }
60
+ export function renderTimelineMarkdown(points, scope) {
61
+ const label = scope === "session" ? "Session" : scope === "month" ? "Month" : "Date";
62
+ return [`| ${label} | Signals | Decisions |`, "|---|---:|---:|", ...points.map((point) => `| ${point.x} | ${point.signalCount} | ${point.decisionCount} |`)].join("\n");
63
+ }
64
+ function buildSessionTimeline(input) {
65
+ const sessions = new Map();
66
+ for (const signal of input.signals) {
67
+ const existing = sessions.get(signal.sessionId) ?? {
68
+ x: signal.sessionId,
69
+ signalCount: 0,
70
+ decisionCount: 0,
71
+ startedAt: signal.capturedAt,
72
+ endedAt: signal.capturedAt,
73
+ durationMinutes: 0,
74
+ };
75
+ existing.signalCount += 1;
76
+ existing.startedAt = minIso(existing.startedAt, signal.capturedAt);
77
+ existing.endedAt = maxIso(existing.endedAt, signal.capturedAt);
78
+ existing.durationMinutes = durationMinutes(existing.startedAt, existing.endedAt);
79
+ sessions.set(signal.sessionId, existing);
80
+ }
81
+ for (const decision of input.decisions) {
82
+ const sessionId = decision.sessionId;
83
+ if (!sessionId) {
84
+ continue;
85
+ }
86
+ const existing = sessions.get(sessionId) ?? {
87
+ x: sessionId,
88
+ signalCount: 0,
89
+ decisionCount: 0,
90
+ startedAt: decision.createdAt,
91
+ endedAt: decision.createdAt,
92
+ durationMinutes: 0,
93
+ };
94
+ existing.decisionCount += 1;
95
+ sessions.set(sessionId, existing);
96
+ }
97
+ return [...sessions.values()]
98
+ .sort((left, right) => String(right.endedAt ?? "").localeCompare(String(left.endedAt ?? "")))
99
+ .slice(0, input.limit ?? 50);
100
+ }
101
+ function minIso(left, right) {
102
+ if (!left)
103
+ return right;
104
+ return left <= right ? left : right;
105
+ }
106
+ function maxIso(left, right) {
107
+ if (!left)
108
+ return right;
109
+ return left >= right ? left : right;
110
+ }
111
+ function durationMinutes(start, end) {
112
+ if (!start || !end)
113
+ return 0;
114
+ return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000));
115
+ }
@@ -1,6 +1,6 @@
1
1
  export declare const PRICING_URL = "https://askthew.com/pricing";
2
2
  export declare const SUPPORT_EMAIL = "support@askthew.com";
3
- export declare function paidDescription(description: string, mode: "paid" | "free" | "unauthenticated"): string;
3
+ export declare function paidDescription(description: string, mode: "paid" | "free" | "free_pending_auth" | "unauthenticated"): string;
4
4
  export declare function paidFeatureNudge(tool: string): {
5
5
  ok: boolean;
6
6
  code: string;
@@ -7,11 +7,18 @@ export function paidDescription(description, mode) {
7
7
  return `${description} (paid - ${PRICING_URL})`;
8
8
  }
9
9
  export function paidFeatureNudge(tool) {
10
+ const feature = tool.includes("outcome")
11
+ ? { label: "Outcomes", verb: "are" }
12
+ : tool.includes("north_star")
13
+ ? { label: "North star", verb: "is" }
14
+ : tool.includes("export")
15
+ ? { label: "Exports", verb: "are" }
16
+ : { label: "This workspace feature", verb: "is" };
10
17
  return {
11
18
  ok: false,
12
19
  code: "free_tier_paid_feature",
13
20
  tool,
14
- message: "This lives in your Ask The W workspace. It becomes available after you upgrade - your local decisions and signals will sync up automatically.",
21
+ message: `${feature.label} ${feature.verb} a paid feature. See ${PRICING_URL}.`,
15
22
  pricingUrl: PRICING_URL,
16
23
  upgradeUrl: `https://askthew.com/mcp?utm_source=mcp-plugin&utm_medium=tool-nudge&utm_campaign=mcp-free&tool=${encodeURIComponent(tool)}`,
17
24
  supportEmail: SUPPORT_EMAIL,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { ensureLocalIdentity, signLocalIdentityPayload } from "./lib/local-identity.js";
8
+ import { identityPath } from "./lib/paths.js";
9
+ test("local identity uses install id as primary identity and signs summary payloads", () => {
10
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-local-identity-"));
11
+ try {
12
+ const env = { ASKTHEW_DATA_DIR: dataDir };
13
+ const identity = ensureLocalIdentity({
14
+ emailClaim: "Founder@Example.com",
15
+ apiUrl: "https://app.askthew.com",
16
+ env,
17
+ });
18
+ const body = JSON.stringify({ schemaVersion: 1, activity: { signalCount: 1 } });
19
+ const signed = signLocalIdentityPayload({ identity, body, timestamp: "2026-05-07T12:00:00.000Z" });
20
+ assert.match(identity.installId, /^[0-9a-f-]{36}$/);
21
+ assert.equal(identity.emailClaim, "founder@example.com");
22
+ assert.equal(fs.existsSync(identityPath(env)), true);
23
+ assert.equal(crypto.verify(null, Buffer.from(`${signed.timestamp}.${body}`), identity.publicKey, Buffer.from(signed.signature, "base64url")), true);
24
+ assert.equal(crypto.verify(null, Buffer.from(`${signed.timestamp}.${JSON.stringify({ tampered: true })}`), identity.publicKey, Buffer.from(signed.signature, "base64url")), false);
25
+ }
26
+ finally {
27
+ fs.rmSync(dataDir, { recursive: true, force: true });
28
+ }
29
+ });
@@ -35,3 +35,37 @@ test("local store migrates, captures signals, decisions, and FIFO telemetry", ()
35
35
  store.close();
36
36
  fs.rmSync(dir, { recursive: true, force: true });
37
37
  });
38
+ test("local store scopes trail rows, links decisions, and tracks lifecycle timestamps", () => {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-store-scope-"));
40
+ const store = LocalStore.open({ path: path.join(dir, "store.sqlite") });
41
+ const scoped = store.insertSignal({
42
+ sessionId: "s1",
43
+ sequence: 1,
44
+ kind: "direction_change",
45
+ summary: "Let's go with local search because it is fast.",
46
+ scopeKey: "repo-a",
47
+ });
48
+ store.insertSignal({
49
+ sessionId: "s2",
50
+ sequence: 1,
51
+ kind: "direction_change",
52
+ summary: "Use a different decision in another repo.",
53
+ scopeKey: "repo-b",
54
+ });
55
+ const decision = store.createDecision({
56
+ rawContent: "Adopt local search.",
57
+ sessionId: "s1",
58
+ sourceSignalIds: [scoped.id],
59
+ scopeKey: "repo-a",
60
+ });
61
+ const committed = store.updateDecision(decision.id, { status: "committed" });
62
+ assert.equal(store.listSignals({ scopeKey: "repo-a" }).length, 1);
63
+ assert.equal(store.listSignals({ scopeKey: "repo-b" }).length, 1);
64
+ assert.equal(store.listDecisions({ scopeKey: "repo-a" }).length, 1);
65
+ assert.equal(store.getDecisionForSignal(scoped.id)?.id, decision.id);
66
+ assert.equal(Boolean(decision.proposedAt), true);
67
+ assert.equal(Boolean(committed?.committedAt), true);
68
+ assert.equal(store.mostRecentSessionId({ scopeKey: "repo-a" }), "s1");
69
+ store.close();
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ });
package/dist/scope.d.ts CHANGED
@@ -4,4 +4,4 @@ export interface PluginScope {
4
4
  appPath?: string;
5
5
  serviceName?: string;
6
6
  }
7
- export declare function resolvePluginScope(startDirectory?: string): PluginScope;
7
+ export declare function resolvePluginScope(startDirectory?: string, env?: NodeJS.ProcessEnv): PluginScope;
package/dist/scope.js CHANGED
@@ -1,6 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  const REPO_MARKERS = [".git", "package.json", "pyproject.toml", "go.mod", "Cargo.toml"];
4
+ const PROJECT_ROOT_ENV_KEYS = [
5
+ "ASKTHEW_REPO_ROOT",
6
+ "ASKTHEW_PROJECT_ROOT",
7
+ "CODEX_WORKSPACE",
8
+ "CODEX_PROJECT_DIR",
9
+ "CLAUDE_PROJECT_DIR",
10
+ "MCP_PROJECT_ROOT",
11
+ "PROJECT_ROOT",
12
+ "WORKSPACE_FOLDER",
13
+ "GITHUB_WORKSPACE",
14
+ "INIT_CWD",
15
+ "PWD",
16
+ ];
4
17
  function parseAskTheWToml(filePath) {
5
18
  if (!fs.existsSync(filePath)) {
6
19
  return {};
@@ -35,10 +48,51 @@ function findRepoRoot(startDirectory) {
35
48
  }
36
49
  return highestMarkerDirectory;
37
50
  }
38
- export function resolvePluginScope(startDirectory = process.cwd()) {
51
+ function cleanEnv(value) {
52
+ return String(value ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
53
+ }
54
+ function explicitEnvScope(env) {
55
+ const repoName = cleanEnv(env.ASKTHEW_REPO_NAME);
56
+ const repoRoot = cleanEnv(env.ASKTHEW_REPO_ROOT);
57
+ const appPath = cleanEnv(env.ASKTHEW_APP_PATH);
58
+ const serviceName = cleanEnv(env.ASKTHEW_SERVICE_NAME);
59
+ if (!repoName && !repoRoot) {
60
+ return null;
61
+ }
62
+ const resolvedRoot = repoRoot ? path.resolve(repoRoot) : undefined;
63
+ return {
64
+ repoName: repoName || path.basename(resolvedRoot ?? process.cwd()),
65
+ ...(resolvedRoot ? { repoRoot: resolvedRoot } : {}),
66
+ ...(appPath ? { appPath } : {}),
67
+ ...(serviceName ? { serviceName } : {}),
68
+ };
69
+ }
70
+ function envRepoRoot(env) {
71
+ for (const key of PROJECT_ROOT_ENV_KEYS) {
72
+ const value = cleanEnv(env[key]);
73
+ if (!value)
74
+ continue;
75
+ const candidate = findRepoRoot(value);
76
+ if (candidate)
77
+ return candidate;
78
+ }
79
+ return null;
80
+ }
81
+ export function resolvePluginScope(startDirectory = process.cwd(), env = process.env) {
39
82
  const cwd = path.resolve(startDirectory);
83
+ const envScope = explicitEnvScope(env);
84
+ if (envScope) {
85
+ return envScope;
86
+ }
40
87
  const parsedConfig = parseAskTheWToml(path.join(cwd, ".asktheworld.toml"));
41
- const repoRoot = parsedConfig.repoRoot || findRepoRoot(cwd) || cwd;
88
+ const discoveredRoot = findRepoRoot(cwd);
89
+ const fallbackEnvRoot = envRepoRoot(env);
90
+ const repoRoot = parsedConfig.repoRoot ||
91
+ (fallbackEnvRoot && (!discoveredRoot || path.basename(discoveredRoot) === "task")
92
+ ? fallbackEnvRoot
93
+ : discoveredRoot) ||
94
+ fallbackEnvRoot ||
95
+ cwd;
42
96
  const repoName = parsedConfig.repoName || path.basename(repoRoot);
43
97
  const relativePath = path.relative(repoRoot, cwd);
44
98
  const normalizedRelativePath = relativePath && relativePath !== "." && !relativePath.startsWith("..")
@@ -30,3 +30,20 @@ test("resolvePluginScope respects explicit .asktheworld.toml values", () => {
30
30
  assert.equal(scope.serviceName, "decisions");
31
31
  fs.rmSync(tempRoot, { recursive: true, force: true });
32
32
  });
33
+ test("resolvePluginScope prefers install-time repo env over npx sandbox cwd", () => {
34
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-scope-env-"));
35
+ const repoRoot = path.join(tempRoot, "ThesisEngine-main");
36
+ const sandboxRoot = path.join(tempRoot, "task");
37
+ fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
38
+ fs.mkdirSync(sandboxRoot, { recursive: true });
39
+ fs.writeFileSync(path.join(sandboxRoot, "package.json"), "{}", "utf8");
40
+ const scope = resolvePluginScope(sandboxRoot, {
41
+ ASKTHEW_REPO_NAME: "ThesisEngine-main",
42
+ ASKTHEW_REPO_ROOT: repoRoot,
43
+ ASKTHEW_APP_PATH: "apps/app",
44
+ });
45
+ assert.equal(scope.repoName, "ThesisEngine-main");
46
+ assert.equal(scope.repoRoot, repoRoot);
47
+ assert.equal(scope.appPath, "apps/app");
48
+ fs.rmSync(tempRoot, { recursive: true, force: true });
49
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildTimelineInsights, buildLocalTimeline } from "./lib/timeline-insights.js";
4
+ function signal(id, day) {
5
+ return {
6
+ id,
7
+ sessionId: "session-a",
8
+ sequence: id,
9
+ kind: "session_checkpoint",
10
+ summary: "checkpoint",
11
+ evidence: [],
12
+ filesTouched: [],
13
+ commandsRun: [],
14
+ metadata: {},
15
+ capturedAt: `${day}T10:00:00.000Z`,
16
+ };
17
+ }
18
+ test("local timeline buckets signals and decisions by day", () => {
19
+ const decisions = [{
20
+ id: "d1",
21
+ sessionId: "session-a",
22
+ headline: "Decision",
23
+ why: null,
24
+ status: "proposed",
25
+ alignment: null,
26
+ files: [],
27
+ sourceSignalIds: [],
28
+ rawContent: "Decision",
29
+ createdAt: "2026-05-01T11:00:00.000Z",
30
+ updatedAt: "2026-05-01T11:00:00.000Z",
31
+ uploadedAt: null,
32
+ }];
33
+ const points = buildLocalTimeline({
34
+ scope: "day",
35
+ signals: [signal(1, "2026-05-01"), signal(2, "2026-05-02")],
36
+ decisions,
37
+ });
38
+ assert.deepEqual(points.map((point) => [point.x, point.signalCount, point.decisionCount]), [
39
+ ["2026-05-01", 1, 1],
40
+ ["2026-05-02", 1, 0],
41
+ ]);
42
+ });
43
+ test("local timeline insight ids stay in timeline namespace", () => {
44
+ const insights = buildTimelineInsights([{ x: "2026-05-01", signalCount: 20, decisionCount: 1 }]);
45
+ assert.ok(insights.every((insight) => insight.id.startsWith("timeline.")));
46
+ });
47
+ test("session timeline buckets by sessionId without inventing Other", () => {
48
+ const decisions = [
49
+ {
50
+ id: "d1",
51
+ sessionId: null,
52
+ headline: "Decision without session",
53
+ why: null,
54
+ status: "proposed",
55
+ alignment: null,
56
+ files: [],
57
+ sourceSignalIds: [],
58
+ rawContent: "Decision without session",
59
+ createdAt: "2026-05-01T11:00:00.000Z",
60
+ updatedAt: "2026-05-01T11:00:00.000Z",
61
+ uploadedAt: null,
62
+ },
63
+ {
64
+ id: "d2",
65
+ sessionId: "session-a",
66
+ headline: "Decision with session",
67
+ why: null,
68
+ status: "proposed",
69
+ alignment: null,
70
+ files: [],
71
+ sourceSignalIds: [],
72
+ rawContent: "Decision with session",
73
+ createdAt: "2026-05-01T11:00:00.000Z",
74
+ updatedAt: "2026-05-01T11:00:00.000Z",
75
+ uploadedAt: null,
76
+ },
77
+ ];
78
+ const points = buildLocalTimeline({
79
+ scope: "session",
80
+ signals: [signal(1, "2026-05-01")],
81
+ decisions,
82
+ });
83
+ assert.deepEqual(points.map((point) => point.x), ["session-a"]);
84
+ assert.equal(points[0]?.decisionCount, 1);
85
+ });
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@askthew/mcp-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
- "description": "Ask The W MCP connector for local-first coding-agent decisions, signals, and review.",
5
+ "description": "Ask The W plugin connector for local-first coding-agent decisions, signals, and review.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",