@askthew/mcp-plugin 0.4.0 → 0.4.3

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
@@ -0,0 +1,52 @@
1
+ import crypto from "node:crypto";
2
+ import { markLocalIdentityRegistered, } from "./local-identity.js";
3
+ function baseUrl(apiUrl) {
4
+ return (apiUrl?.trim() || process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com").replace(/\/$/, "");
5
+ }
6
+ function hashClaimCode(code) {
7
+ return crypto.createHash("sha256").update(code.trim()).digest("hex");
8
+ }
9
+ function safeMessage(error) {
10
+ return error instanceof Error ? error.message : "Registration failed.";
11
+ }
12
+ export async function registerFreeInstall(input) {
13
+ const fetcher = input.options?.fetchImpl ?? fetch;
14
+ const response = await fetcher(`${baseUrl(input.options?.apiUrl ?? input.identity.apiUrl)}/api/cli/v1/free-installs/register`, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({
18
+ installId: input.identity.installId,
19
+ publicKey: input.identity.publicKey,
20
+ claimCodeHash: hashClaimCode(input.identity.claimCode),
21
+ emailClaim: input.identity.emailClaim,
22
+ deviceLabel: input.deviceLabel,
23
+ repo: input.repo ?? {},
24
+ }),
25
+ });
26
+ const payload = await response.json().catch(() => null);
27
+ if (!response.ok) {
28
+ throw new Error(payload?.error ? String(payload.error) : "Free install registration failed.");
29
+ }
30
+ const registeredAt = payload && typeof payload === "object" && typeof payload.registeredAt === "string"
31
+ ? payload.registeredAt
32
+ : new Date().toISOString();
33
+ markLocalIdentityRegistered({ registeredAt });
34
+ return { ok: true, registeredAt };
35
+ }
36
+ export async function tryRegisterFreeInstall(input) {
37
+ try {
38
+ return await registerFreeInstall(input);
39
+ }
40
+ catch (error) {
41
+ markLocalIdentityRegistered({ registrationError: safeMessage(error) });
42
+ return { ok: false, error: safeMessage(error) };
43
+ }
44
+ }
45
+ export function describeFreeIdentity(identity) {
46
+ return [
47
+ `Install ID: ${identity.installId}`,
48
+ identity.emailClaim ? `Email claim: ${identity.emailClaim} (unverified)` : "Email claim: none",
49
+ `Claim code: ${identity.claimCode}`,
50
+ identity.registeredAt ? `Registered: ${identity.registeredAt}` : "Registered: pending",
51
+ ].join("\n");
52
+ }
@@ -1,4 +1,5 @@
1
- export type McpMode = "paid" | "free" | "unauthenticated";
1
+ import { type LocalInstallIdentity } from "./local-identity.js";
2
+ export type McpMode = "paid" | "free" | "free_pending_auth" | "unauthenticated";
2
3
  export interface CliCredentials {
3
4
  email?: string;
4
5
  userId: string;
@@ -7,6 +8,9 @@ export interface CliCredentials {
7
8
  apiUrl?: string;
8
9
  telemetryOptOut?: boolean;
9
10
  accountStatus?: "new_dormant" | "existing_active";
11
+ identityKind?: "legacy_token" | "local_install";
12
+ installId?: string;
13
+ localIdentity?: LocalInstallIdentity;
10
14
  }
11
15
  export interface ModeResolution {
12
16
  mode: McpMode;
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { credentialsPath, readJsonFile } from "./paths.js";
3
+ import { loadLocalIdentity } from "./local-identity.js";
3
4
  function clean(value) {
4
5
  return String(value ?? "").trim().replace(/^['"]/, "").replace(/['"]$/, "");
5
6
  }
@@ -14,6 +15,20 @@ export function loadCliCredentials(env = process.env) {
14
15
  telemetryOptOut: env.ASKTHEW_TELEMETRY === "off",
15
16
  };
16
17
  }
18
+ const localIdentity = loadLocalIdentity(env);
19
+ if (localIdentity) {
20
+ return {
21
+ email: localIdentity.emailClaim,
22
+ userId: localIdentity.installId,
23
+ cliToken: localIdentity.installId,
24
+ cliTokenId: localIdentity.installId,
25
+ apiUrl: localIdentity.apiUrl,
26
+ telemetryOptOut: localIdentity.telemetryOptOut,
27
+ identityKind: "local_install",
28
+ installId: localIdentity.installId,
29
+ localIdentity,
30
+ };
31
+ }
17
32
  const creds = readJsonFile(credentialsPath(env));
18
33
  if (!creds?.cliToken || !creds.userId || !creds.cliTokenId) {
19
34
  return null;
@@ -39,7 +54,7 @@ export function resolveMcpMode(env = process.env) {
39
54
  }
40
55
  if (clean(env.ASKTHEW_FREE_MODE) === "1" || clean(env.ASKTHEW_FREE_MODE).toLowerCase() === "true") {
41
56
  return {
42
- mode: "unauthenticated",
57
+ mode: "free_pending_auth",
43
58
  reason: fs.existsSync(credentialsPath(env)) ? "invalid_cli_credentials" : "free_mode_no_credentials",
44
59
  };
45
60
  }
@@ -0,0 +1,44 @@
1
+ export interface LocalInstallIdentity {
2
+ installId: string;
3
+ privateKey: string;
4
+ publicKey: string;
5
+ claimCode: string;
6
+ emailClaim?: string;
7
+ createdAt: string;
8
+ registeredAt?: string;
9
+ registrationError?: string;
10
+ apiUrl?: string;
11
+ telemetryOptOut?: boolean;
12
+ }
13
+ export interface PublicInstallIdentity {
14
+ installId: string;
15
+ publicKey: string;
16
+ claimCode: string;
17
+ emailClaim?: string;
18
+ createdAt: string;
19
+ registeredAt?: string;
20
+ registrationError?: string;
21
+ apiUrl?: string;
22
+ telemetryOptOut?: boolean;
23
+ }
24
+ export declare function loadLocalIdentity(env?: NodeJS.ProcessEnv): LocalInstallIdentity | null;
25
+ export declare function publicIdentity(identity: LocalInstallIdentity): PublicInstallIdentity;
26
+ export declare function ensureLocalIdentity(input?: {
27
+ emailClaim?: string | null;
28
+ apiUrl?: string;
29
+ telemetryOptOut?: boolean;
30
+ env?: NodeJS.ProcessEnv;
31
+ }): LocalInstallIdentity;
32
+ export declare function markLocalIdentityRegistered(input: {
33
+ registeredAt?: string;
34
+ registrationError?: string;
35
+ env?: NodeJS.ProcessEnv;
36
+ }): LocalInstallIdentity | null;
37
+ export declare function signLocalIdentityPayload(input: {
38
+ identity: LocalInstallIdentity;
39
+ body: string;
40
+ timestamp?: string;
41
+ }): {
42
+ timestamp: string;
43
+ signature: string;
44
+ };
@@ -0,0 +1,81 @@
1
+ import crypto from "node:crypto";
2
+ import { identityPath, readJsonFile, writePrivateJson } from "./paths.js";
3
+ function normalizeEmail(email) {
4
+ const value = String(email ?? "").trim().toLowerCase();
5
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) ? value : undefined;
6
+ }
7
+ function generateClaimCode() {
8
+ return crypto.randomBytes(6).toString("base64url").toUpperCase();
9
+ }
10
+ export function loadLocalIdentity(env = process.env) {
11
+ const identity = readJsonFile(identityPath(env));
12
+ if (!identity?.installId || !identity.privateKey || !identity.publicKey || !identity.claimCode) {
13
+ return null;
14
+ }
15
+ return identity;
16
+ }
17
+ export function publicIdentity(identity) {
18
+ return {
19
+ installId: identity.installId,
20
+ publicKey: identity.publicKey,
21
+ claimCode: identity.claimCode,
22
+ emailClaim: identity.emailClaim,
23
+ createdAt: identity.createdAt,
24
+ registeredAt: identity.registeredAt,
25
+ registrationError: identity.registrationError,
26
+ apiUrl: identity.apiUrl,
27
+ telemetryOptOut: identity.telemetryOptOut,
28
+ };
29
+ }
30
+ export function ensureLocalIdentity(input = {}) {
31
+ const env = input.env ?? process.env;
32
+ const existing = loadLocalIdentity(env);
33
+ const emailClaim = normalizeEmail(input.emailClaim);
34
+ if (existing) {
35
+ const next = {
36
+ ...existing,
37
+ ...(emailClaim ? { emailClaim } : {}),
38
+ ...(input.apiUrl ? { apiUrl: input.apiUrl } : {}),
39
+ ...(typeof input.telemetryOptOut === "boolean" ? { telemetryOptOut: input.telemetryOptOut } : {}),
40
+ };
41
+ writePrivateJson(identityPath(env), next);
42
+ return next;
43
+ }
44
+ const keyPair = crypto.generateKeyPairSync("ed25519", {
45
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
46
+ publicKeyEncoding: { type: "spki", format: "pem" },
47
+ });
48
+ const identity = {
49
+ installId: crypto.randomUUID(),
50
+ privateKey: keyPair.privateKey,
51
+ publicKey: keyPair.publicKey,
52
+ claimCode: generateClaimCode(),
53
+ ...(emailClaim ? { emailClaim } : {}),
54
+ ...(input.apiUrl ? { apiUrl: input.apiUrl } : {}),
55
+ ...(typeof input.telemetryOptOut === "boolean" ? { telemetryOptOut: input.telemetryOptOut } : {}),
56
+ createdAt: new Date().toISOString(),
57
+ };
58
+ writePrivateJson(identityPath(env), identity);
59
+ return identity;
60
+ }
61
+ export function markLocalIdentityRegistered(input) {
62
+ const env = input.env ?? process.env;
63
+ const identity = loadLocalIdentity(env);
64
+ if (!identity)
65
+ return null;
66
+ const next = {
67
+ ...identity,
68
+ registeredAt: input.registrationError ? identity.registeredAt : (input.registeredAt ?? new Date().toISOString()),
69
+ registrationError: input.registrationError,
70
+ };
71
+ writePrivateJson(identityPath(env), next);
72
+ return next;
73
+ }
74
+ export function signLocalIdentityPayload(input) {
75
+ const timestamp = input.timestamp ?? new Date().toISOString();
76
+ const signature = crypto.sign(null, Buffer.from(`${timestamp}.${input.body}`), input.identity.privateKey).toString("base64url");
77
+ return {
78
+ timestamp,
79
+ signature,
80
+ };
81
+ }
@@ -9,10 +9,20 @@ export interface LocalSignalInput {
9
9
  filesTouched?: string[];
10
10
  commandsRun?: string[];
11
11
  metadata?: Record<string, unknown>;
12
+ scopeKey?: string | null;
12
13
  }
13
- export interface LocalSignal extends Required<LocalSignalInput> {
14
+ export interface LocalSignal {
14
15
  id: number;
16
+ sessionId: string;
17
+ sequence: number;
18
+ kind: SignalKind;
19
+ summary: string;
20
+ evidence: unknown[];
21
+ filesTouched: string[];
22
+ commandsRun: string[];
23
+ metadata: Record<string, unknown>;
15
24
  capturedAt: string;
25
+ scopeKey?: string | null;
16
26
  }
17
27
  export interface LocalDecision {
18
28
  id: string;
@@ -27,6 +37,11 @@ export interface LocalDecision {
27
37
  createdAt: string;
28
38
  updatedAt: string;
29
39
  uploadedAt: string | null;
40
+ scopeKey?: string | null;
41
+ proposedAt?: string | null;
42
+ committedAt?: string | null;
43
+ shippedAt?: string | null;
44
+ abandonedAt?: string | null;
30
45
  }
31
46
  export interface LocalDecisionInput {
32
47
  id?: string;
@@ -38,6 +53,7 @@ export interface LocalDecisionInput {
38
53
  files?: string[];
39
54
  sourceSignalIds?: number[];
40
55
  rawContent: string;
56
+ scopeKey?: string | null;
41
57
  }
42
58
  export interface TelemetryOutboxRow {
43
59
  id: number;
@@ -65,11 +81,16 @@ export declare class LocalStore {
65
81
  insertSignal(input: LocalSignalInput): LocalSignal;
66
82
  listSignals(input?: {
67
83
  sessionId?: string;
84
+ scopeKey?: string | null;
85
+ since?: string;
68
86
  limit?: number;
87
+ cursor?: string;
69
88
  uploaded?: boolean;
70
89
  }): LocalSignal[];
71
90
  getSignal(id: number): LocalSignal | null;
72
- mostRecentSessionId(): any;
91
+ mostRecentSessionId(input?: {
92
+ scopeKey?: string | null;
93
+ }): any;
73
94
  createDecision(input: LocalDecisionInput): LocalDecision;
74
95
  updateDecision(id: string, patch: Partial<Omit<LocalDecision, "id" | "createdAt">>): LocalDecision | null;
75
96
  deleteDecision(id: string): boolean;
@@ -78,8 +99,17 @@ export declare class LocalStore {
78
99
  status?: DecisionStatus;
79
100
  limit?: number;
80
101
  since?: string;
102
+ cursor?: string;
103
+ sessionId?: string;
104
+ scopeKey?: string | null;
81
105
  pendingUploadOnly?: boolean;
82
106
  }): LocalDecision[];
107
+ listSessionIds(input?: {
108
+ limit?: number;
109
+ scopeKey?: string | null;
110
+ }): string[];
111
+ listSignalsByIds(ids: number[]): LocalSignal[];
112
+ getDecisionForSignal(signalId: number): LocalDecision | null;
83
113
  enqueueTelemetry(payload: Record<string, unknown>): number;
84
114
  listTelemetryOutbox(input?: {
85
115
  undeliveredOnly?: boolean;
@@ -96,4 +126,5 @@ export declare class LocalStore {
96
126
  private openDatabase;
97
127
  private persistJson;
98
128
  private nextJsonId;
129
+ private addColumnIfMissing;
99
130
  }
@@ -55,6 +55,7 @@ export class LocalStore {
55
55
  files_json text not null default '[]',
56
56
  commands_json text not null default '[]',
57
57
  metadata_json text not null default '{}',
58
+ scope_key text,
58
59
  captured_at text not null,
59
60
  uploaded_at text,
60
61
  unique(session_id, sequence)
@@ -69,6 +70,11 @@ export class LocalStore {
69
70
  files_json text not null default '[]',
70
71
  source_signal_ids text not null default '[]',
71
72
  raw_content text not null,
73
+ scope_key text,
74
+ proposed_at text,
75
+ committed_at text,
76
+ shipped_at text,
77
+ abandoned_at text,
72
78
  created_at text not null,
73
79
  updated_at text not null,
74
80
  uploaded_at text
@@ -82,10 +88,16 @@ export class LocalStore {
82
88
  delivered_at text
83
89
  );
84
90
  `);
85
- this.setMeta("schema_version", "1");
91
+ this.addColumnIfMissing("signals", "scope_key", "alter table signals add column scope_key text");
92
+ this.addColumnIfMissing("decisions", "scope_key", "alter table decisions add column scope_key text");
93
+ this.addColumnIfMissing("decisions", "proposed_at", "alter table decisions add column proposed_at text");
94
+ this.addColumnIfMissing("decisions", "committed_at", "alter table decisions add column committed_at text");
95
+ this.addColumnIfMissing("decisions", "shipped_at", "alter table decisions add column shipped_at text");
96
+ this.addColumnIfMissing("decisions", "abandoned_at", "alter table decisions add column abandoned_at text");
97
+ this.setMeta("schema_version", "2");
86
98
  return;
87
99
  }
88
- this.data.meta.schema_version = "1";
100
+ this.data.meta.schema_version = "2";
89
101
  this.persistJson();
90
102
  }
91
103
  getMeta(key) {
@@ -115,9 +127,9 @@ export class LocalStore {
115
127
  }
116
128
  const result = this.db
117
129
  .prepare(`insert into signals
118
- (session_id, sequence, kind, summary, evidence_json, files_json, commands_json, metadata_json, captured_at)
119
- values (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
120
- .run(input.sessionId, input.sequence, input.kind, input.summary, JSON.stringify(input.evidence ?? []), JSON.stringify(input.filesTouched ?? []), JSON.stringify(input.commandsRun ?? []), JSON.stringify(input.metadata ?? {}), now);
130
+ (session_id, sequence, kind, summary, evidence_json, files_json, commands_json, metadata_json, scope_key, captured_at)
131
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
132
+ .run(input.sessionId, input.sequence, input.kind, input.summary, JSON.stringify(input.evidence ?? []), JSON.stringify(input.filesTouched ?? []), JSON.stringify(input.commandsRun ?? []), JSON.stringify(input.metadata ?? {}), input.scopeKey ?? null, now);
121
133
  const id = Number(result.lastInsertRowid ?? 0);
122
134
  return this.getSignal(id);
123
135
  }
@@ -135,6 +147,7 @@ export class LocalStore {
135
147
  filesTouched: input.filesTouched ?? [],
136
148
  commandsRun: input.commandsRun ?? [],
137
149
  metadata: input.metadata ?? {},
150
+ scopeKey: input.scopeKey ?? null,
138
151
  capturedAt: now,
139
152
  };
140
153
  this.data.signals.push(signal);
@@ -144,14 +157,51 @@ export class LocalStore {
144
157
  listSignals(input = {}) {
145
158
  const limit = input.limit ?? 300;
146
159
  if (this.db) {
147
- const where = input.sessionId ? "where session_id = ?" : "";
160
+ const clauses = [];
161
+ const params = [];
162
+ if (input.sessionId) {
163
+ clauses.push("session_id = ?");
164
+ params.push(input.sessionId);
165
+ }
166
+ if (input.scopeKey) {
167
+ clauses.push("scope_key = ?");
168
+ params.push(input.scopeKey);
169
+ }
170
+ if (input.since) {
171
+ clauses.push("captured_at >= ?");
172
+ params.push(input.since);
173
+ }
174
+ if (input.cursor) {
175
+ const cursorTime = new Date(input.cursor).getTime();
176
+ if (Number.isFinite(cursorTime)) {
177
+ clauses.push("captured_at > ?");
178
+ params.push(new Date(cursorTime).toISOString());
179
+ }
180
+ else if (/^\d+$/.test(input.cursor)) {
181
+ clauses.push("id > ?");
182
+ params.push(Number(input.cursor));
183
+ }
184
+ }
185
+ const where = clauses.length > 0 ? `where ${clauses.join(" and ")}` : "";
148
186
  const rows = this.db
149
187
  .prepare(`select * from signals ${where} order by captured_at asc, id asc limit ?`)
150
- .all(...(input.sessionId ? [input.sessionId, limit] : [limit]));
188
+ .all(...params, limit);
151
189
  return rows.map(rowToSignal);
152
190
  }
153
191
  return this.data.signals
154
192
  .filter((signal) => !input.sessionId || signal.sessionId === input.sessionId)
193
+ .filter((signal) => !input.scopeKey || signal.scopeKey === input.scopeKey)
194
+ .filter((signal) => !input.since || signal.capturedAt >= input.since)
195
+ .filter((signal) => {
196
+ if (!input.cursor)
197
+ return true;
198
+ const cursorTime = new Date(input.cursor).getTime();
199
+ if (Number.isFinite(cursorTime))
200
+ return signal.capturedAt > new Date(cursorTime).toISOString();
201
+ if (/^\d+$/.test(input.cursor))
202
+ return signal.id > Number(input.cursor);
203
+ return true;
204
+ })
155
205
  .sort((a, b) => a.capturedAt.localeCompare(b.capturedAt) || a.id - b.id)
156
206
  .slice(0, limit);
157
207
  }
@@ -162,16 +212,17 @@ export class LocalStore {
162
212
  }
163
213
  return this.data.signals.find((signal) => signal.id === id) ?? null;
164
214
  }
165
- mostRecentSessionId() {
166
- const signal = this.listSignals({ limit: 1 }).at(-1);
167
- if (signal) {
168
- return signal.sessionId;
169
- }
215
+ mostRecentSessionId(input = {}) {
170
216
  if (this.db) {
171
- const row = this.db.prepare("select session_id from signals order by captured_at desc, id desc limit 1").get();
217
+ const row = input.scopeKey
218
+ ? this.db.prepare("select session_id from signals where scope_key = ? order by captured_at desc, id desc limit 1").get(input.scopeKey)
219
+ : this.db.prepare("select session_id from signals order by captured_at desc, id desc limit 1").get();
172
220
  return typeof row?.session_id === "string" ? row.session_id : null;
173
221
  }
174
- return this.data.signals.at(-1)?.sessionId ?? null;
222
+ return this.data.signals
223
+ .filter((signal) => !input.scopeKey || signal.scopeKey === input.scopeKey)
224
+ .sort((left, right) => left.capturedAt.localeCompare(right.capturedAt) || left.id - right.id)
225
+ .at(-1)?.sessionId ?? null;
175
226
  }
176
227
  createDecision(input) {
177
228
  const now = new Date().toISOString();
@@ -190,13 +241,18 @@ export class LocalStore {
190
241
  createdAt: now,
191
242
  updatedAt: now,
192
243
  uploadedAt: null,
244
+ scopeKey: input.scopeKey ?? null,
245
+ proposedAt: (input.status ?? "proposed") === "proposed" ? now : null,
246
+ committedAt: input.status === "committed" ? now : null,
247
+ shippedAt: input.status === "shipped" ? now : null,
248
+ abandonedAt: input.status === "abandoned" ? now : null,
193
249
  };
194
250
  if (this.db) {
195
251
  this.db
196
252
  .prepare(`insert into decisions
197
- (id, session_id, headline, why, status, alignment, files_json, source_signal_ids, raw_content, created_at, updated_at, uploaded_at)
198
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
199
- .run(decision.id, decision.sessionId, decision.headline, decision.why, decision.status, decision.alignment, JSON.stringify(decision.files), JSON.stringify(decision.sourceSignalIds), decision.rawContent, decision.createdAt, decision.updatedAt, decision.uploadedAt);
253
+ (id, session_id, headline, why, status, alignment, files_json, source_signal_ids, raw_content, scope_key, proposed_at, committed_at, shipped_at, abandoned_at, created_at, updated_at, uploaded_at)
254
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
255
+ .run(decision.id, decision.sessionId, decision.headline, decision.why, decision.status, decision.alignment, JSON.stringify(decision.files), JSON.stringify(decision.sourceSignalIds), decision.rawContent, decision.scopeKey, decision.proposedAt, decision.committedAt, decision.shippedAt, decision.abandonedAt, decision.createdAt, decision.updatedAt, decision.uploadedAt);
200
256
  return decision;
201
257
  }
202
258
  this.data.decisions.push(decision);
@@ -213,11 +269,22 @@ export class LocalStore {
213
269
  ...patch,
214
270
  updatedAt: new Date().toISOString(),
215
271
  };
272
+ if (patch.status && patch.status !== existing.status) {
273
+ if (patch.status === "proposed" && !next.proposedAt)
274
+ next.proposedAt = next.updatedAt;
275
+ if (patch.status === "committed" && !next.committedAt)
276
+ next.committedAt = next.updatedAt;
277
+ if (patch.status === "shipped" && !next.shippedAt)
278
+ next.shippedAt = next.updatedAt;
279
+ if (patch.status === "abandoned" && !next.abandonedAt)
280
+ next.abandonedAt = next.updatedAt;
281
+ }
216
282
  if (this.db) {
217
283
  this.db
218
284
  .prepare(`update decisions set headline = ?, why = ?, status = ?, alignment = ?, files_json = ?,
219
- source_signal_ids = ?, raw_content = ?, updated_at = ?, uploaded_at = ? where id = ?`)
220
- .run(next.headline, next.why, next.status, next.alignment, JSON.stringify(next.files), JSON.stringify(next.sourceSignalIds), next.rawContent, next.updatedAt, next.uploadedAt, id);
285
+ source_signal_ids = ?, raw_content = ?, scope_key = ?, proposed_at = ?, committed_at = ?,
286
+ shipped_at = ?, abandoned_at = ?, updated_at = ?, uploaded_at = ? where id = ?`)
287
+ .run(next.headline, next.why, next.status, next.alignment, JSON.stringify(next.files), JSON.stringify(next.sourceSignalIds), next.rawContent, next.scopeKey, next.proposedAt, next.committedAt, next.shippedAt, next.abandonedAt, next.updatedAt, next.uploadedAt, id);
221
288
  return next;
222
289
  }
223
290
  this.data.decisions = this.data.decisions.map((decision) => (decision.id === id ? next : decision));
@@ -245,6 +312,14 @@ export class LocalStore {
245
312
  if (this.db) {
246
313
  const clauses = [];
247
314
  const params = [];
315
+ if (input.sessionId) {
316
+ clauses.push("session_id = ?");
317
+ params.push(input.sessionId);
318
+ }
319
+ if (input.scopeKey) {
320
+ clauses.push("scope_key = ?");
321
+ params.push(input.scopeKey);
322
+ }
248
323
  if (input.status) {
249
324
  clauses.push("status = ?");
250
325
  params.push(input.status);
@@ -253,6 +328,17 @@ export class LocalStore {
253
328
  clauses.push("created_at >= ?");
254
329
  params.push(input.since);
255
330
  }
331
+ if (input.cursor) {
332
+ const cursorTime = new Date(input.cursor).getTime();
333
+ if (Number.isFinite(cursorTime)) {
334
+ clauses.push("created_at < ?");
335
+ params.push(new Date(cursorTime).toISOString());
336
+ }
337
+ else {
338
+ clauses.push("id < ?");
339
+ params.push(input.cursor);
340
+ }
341
+ }
256
342
  if (input.pendingUploadOnly) {
257
343
  clauses.push("uploaded_at is null");
258
344
  }
@@ -263,12 +349,83 @@ export class LocalStore {
263
349
  .map(rowToDecision);
264
350
  }
265
351
  return this.data.decisions
352
+ .filter((decision) => !input.sessionId || decision.sessionId === input.sessionId)
353
+ .filter((decision) => !input.scopeKey || decision.scopeKey === input.scopeKey)
266
354
  .filter((decision) => !input.status || decision.status === input.status)
267
355
  .filter((decision) => !input.since || decision.createdAt >= input.since)
356
+ .filter((decision) => {
357
+ if (!input.cursor)
358
+ return true;
359
+ const cursorTime = new Date(input.cursor).getTime();
360
+ if (Number.isFinite(cursorTime))
361
+ return decision.createdAt < new Date(cursorTime).toISOString();
362
+ return decision.id < input.cursor;
363
+ })
268
364
  .filter((decision) => !input.pendingUploadOnly || !decision.uploadedAt)
269
365
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
270
366
  .slice(0, limit);
271
367
  }
368
+ listSessionIds(input = {}) {
369
+ const limit = input.limit ?? 100000;
370
+ if (this.db) {
371
+ const rows = this.db
372
+ .prepare(`select session_id, max(captured_at) as last_seen_at
373
+ from signals
374
+ ${input.scopeKey ? "where scope_key = ?" : ""}
375
+ group by session_id
376
+ order by last_seen_at desc
377
+ limit ?`)
378
+ .all(...(input.scopeKey ? [input.scopeKey, limit] : [limit]));
379
+ return rows.map((row) => String(row.session_id));
380
+ }
381
+ const lastSeen = new Map();
382
+ for (const signal of this.data.signals) {
383
+ if (input.scopeKey && signal.scopeKey !== input.scopeKey) {
384
+ continue;
385
+ }
386
+ const current = lastSeen.get(signal.sessionId);
387
+ if (!current || signal.capturedAt > current) {
388
+ lastSeen.set(signal.sessionId, signal.capturedAt);
389
+ }
390
+ }
391
+ return Array.from(lastSeen.entries())
392
+ .sort((left, right) => right[1].localeCompare(left[1]))
393
+ .slice(0, limit)
394
+ .map(([sessionId]) => sessionId);
395
+ }
396
+ listSignalsByIds(ids) {
397
+ const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isFinite(id))));
398
+ if (uniqueIds.length === 0) {
399
+ return [];
400
+ }
401
+ if (this.db) {
402
+ const placeholders = uniqueIds.map(() => "?").join(", ");
403
+ return this.db
404
+ .prepare(`select * from signals where id in (${placeholders}) order by captured_at asc, id asc`)
405
+ .all(...uniqueIds)
406
+ .map(rowToSignal);
407
+ }
408
+ const idSet = new Set(uniqueIds);
409
+ return this.data.signals
410
+ .filter((signal) => idSet.has(signal.id))
411
+ .sort((a, b) => a.capturedAt.localeCompare(b.capturedAt) || a.id - b.id);
412
+ }
413
+ getDecisionForSignal(signalId) {
414
+ if (this.db) {
415
+ const rows = this.db.prepare("select * from decisions order by created_at desc").all();
416
+ for (const row of rows) {
417
+ const decision = rowToDecision(row);
418
+ if (decision.sourceSignalIds.includes(signalId)) {
419
+ return decision;
420
+ }
421
+ }
422
+ return null;
423
+ }
424
+ return (this.data.decisions
425
+ .slice()
426
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
427
+ .find((decision) => decision.sourceSignalIds.includes(signalId)) ?? null);
428
+ }
272
429
  enqueueTelemetry(payload) {
273
430
  const now = new Date().toISOString();
274
431
  if (this.db) {
@@ -356,6 +513,15 @@ export class LocalStore {
356
513
  nextJsonId(rows) {
357
514
  return rows.reduce((max, row) => Math.max(max, row.id), 0) + 1;
358
515
  }
516
+ addColumnIfMissing(table, column, statement) {
517
+ if (!this.db)
518
+ return;
519
+ const rows = this.db.prepare(`pragma table_info(${table})`).all();
520
+ if (rows.some((row) => row.name === column)) {
521
+ return;
522
+ }
523
+ this.db.exec(statement);
524
+ }
359
525
  }
360
526
  function tryRequireBetterSqlite3() {
361
527
  try {
@@ -389,6 +555,7 @@ function rowToSignal(row) {
389
555
  filesTouched: parseJson(row.files_json, []),
390
556
  commandsRun: parseJson(row.commands_json, []),
391
557
  metadata: parseJson(row.metadata_json, {}),
558
+ scopeKey: typeof row.scope_key === "string" ? row.scope_key : null,
392
559
  capturedAt: String(row.captured_at),
393
560
  };
394
561
  }
@@ -406,6 +573,11 @@ function rowToDecision(row) {
406
573
  createdAt: String(row.created_at),
407
574
  updatedAt: String(row.updated_at),
408
575
  uploadedAt: typeof row.uploaded_at === "string" ? row.uploaded_at : null,
576
+ scopeKey: typeof row.scope_key === "string" ? row.scope_key : null,
577
+ proposedAt: typeof row.proposed_at === "string" ? row.proposed_at : null,
578
+ committedAt: typeof row.committed_at === "string" ? row.committed_at : null,
579
+ shippedAt: typeof row.shipped_at === "string" ? row.shipped_at : null,
580
+ abandonedAt: typeof row.abandoned_at === "string" ? row.abandoned_at : null,
409
581
  };
410
582
  }
411
583
  function rowToTelemetry(row) {
@@ -2,6 +2,8 @@ export declare function askTheWDataDir(env?: NodeJS.ProcessEnv): string;
2
2
  export declare function ensureAskTheWDataDir(env?: NodeJS.ProcessEnv): string;
3
3
  export declare function localStorePath(env?: NodeJS.ProcessEnv): string;
4
4
  export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
5
+ export declare function identityPath(env?: NodeJS.ProcessEnv): string;
6
+ export declare function configPath(env?: NodeJS.ProcessEnv): string;
5
7
  export declare function jsonFallbackStorePath(env?: NodeJS.ProcessEnv): string;
6
8
  export declare function writePrivateJson(filePath: string, value: unknown): void;
7
9
  export declare function readJsonFile<T>(filePath: string): T | null;
package/dist/lib/paths.js CHANGED
@@ -23,6 +23,12 @@ export function localStorePath(env = process.env) {
23
23
  export function credentialsPath(env = process.env) {
24
24
  return path.join(askTheWDataDir(env), "credentials.json");
25
25
  }
26
+ export function identityPath(env = process.env) {
27
+ return path.join(askTheWDataDir(env), "identity.json");
28
+ }
29
+ export function configPath(env = process.env) {
30
+ return path.join(askTheWDataDir(env), "config.json");
31
+ }
26
32
  export function jsonFallbackStorePath(env = process.env) {
27
33
  return path.join(askTheWDataDir(env), "store.json");
28
34
  }