@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.
- package/README.md +24 -13
- package/dist/auth-pending.test.d.ts +1 -0
- package/dist/auth-pending.test.js +56 -0
- package/dist/cli-actions.test.d.ts +1 -0
- package/dist/cli-actions.test.js +71 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +293 -37
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +274 -0
- package/dist/free-tier-policy.test.d.ts +1 -0
- package/dist/free-tier-policy.test.js +57 -0
- package/dist/index.d.ts +47 -13
- package/dist/index.js +1103 -106
- package/dist/index.test.js +609 -6
- package/dist/install.d.ts +40 -0
- package/dist/install.js +155 -18
- package/dist/install.test.js +62 -2
- package/dist/lib/auth-pending.d.ts +23 -0
- package/dist/lib/auth-pending.js +36 -0
- package/dist/lib/cli-actions.d.ts +28 -0
- package/dist/lib/cli-actions.js +104 -0
- package/dist/lib/free-install-registration.d.ts +27 -0
- package/dist/lib/free-install-registration.js +52 -0
- package/dist/lib/free-tier-policy.d.ts +5 -1
- package/dist/lib/free-tier-policy.js +16 -1
- package/dist/lib/local-identity.d.ts +44 -0
- package/dist/lib/local-identity.js +81 -0
- package/dist/lib/local-store.d.ts +33 -2
- package/dist/lib/local-store.js +191 -19
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/telemetry.js +28 -2
- package/dist/lib/timeline-insights.d.ts +23 -0
- package/dist/lib/timeline-insights.js +115 -0
- package/dist/lib/upgrade-nudge.d.ts +1 -1
- package/dist/lib/upgrade-nudge.js +8 -1
- package/dist/local-identity.test.d.ts +1 -0
- package/dist/local-identity.test.js +29 -0
- package/dist/local-store.test.js +34 -0
- package/dist/scope.d.ts +1 -1
- package/dist/scope.js +56 -2
- package/dist/scope.test.js +17 -0
- package/dist/timeline-insights.test.d.ts +1 -0
- package/dist/timeline-insights.test.js +85 -0
- 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
|
-
|
|
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: "
|
|
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
|
|
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(
|
|
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
|
}
|
package/dist/lib/local-store.js
CHANGED
|
@@ -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.
|
|
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 = "
|
|
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
|
|
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(...
|
|
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 =
|
|
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
|
|
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 = ?,
|
|
220
|
-
|
|
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) {
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -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
|
}
|