@askthew/mcp-plugin 0.2.8 → 0.4.0
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 +49 -11
- package/dist/cli.js +148 -10
- package/dist/index.d.ts +23 -11
- package/dist/index.js +731 -95
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +349 -0
- package/dist/install.d.ts +16 -1
- package/dist/install.js +16 -8
- package/dist/install.test.d.ts +1 -0
- package/dist/install.test.js +237 -0
- package/dist/lib/auth-magic-link.d.ts +22 -0
- package/dist/lib/auth-magic-link.js +43 -0
- package/dist/lib/free-tier-policy.d.ts +19 -0
- package/dist/lib/free-tier-policy.js +53 -0
- package/dist/lib/local-store.d.ts +99 -0
- package/dist/lib/local-store.js +423 -0
- package/dist/lib/loopback-auth.d.ts +8 -0
- package/dist/lib/loopback-auth.js +30 -0
- package/dist/lib/paths.d.ts +7 -0
- package/dist/lib/paths.js +44 -0
- package/dist/lib/telemetry.d.ts +25 -0
- package/dist/lib/telemetry.js +133 -0
- package/dist/lib/tip-engine.d.ts +18 -0
- package/dist/lib/tip-engine.js +237 -0
- package/dist/lib/upgrade-nudge.d.ts +19 -0
- package/dist/lib/upgrade-nudge.js +30 -0
- package/dist/lib/upgrade-sync.d.ts +38 -0
- package/dist/lib/upgrade-sync.js +60 -0
- package/dist/local-store.test.d.ts +1 -0
- package/dist/local-store.test.js +37 -0
- package/dist/scope.d.ts +0 -1
- package/dist/scope.js +0 -6
- package/dist/scope.test.d.ts +1 -0
- package/dist/scope.test.js +32 -0
- package/dist/tip-engine.test.d.ts +1 -0
- package/dist/tip-engine.test.js +51 -0
- package/package.json +7 -10
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { jsonFallbackStorePath, localStorePath, readJsonFile, writePrivateJson } from "./paths.js";
|
|
6
|
+
export class LocalStore {
|
|
7
|
+
storePath;
|
|
8
|
+
data;
|
|
9
|
+
db = null;
|
|
10
|
+
jsonMode = false;
|
|
11
|
+
jsonPath;
|
|
12
|
+
constructor(storePath, data) {
|
|
13
|
+
this.storePath = storePath;
|
|
14
|
+
this.data = data;
|
|
15
|
+
this.jsonPath = jsonFallbackStorePath();
|
|
16
|
+
}
|
|
17
|
+
static open(input = {}) {
|
|
18
|
+
const store = new LocalStore(input.path ?? localStorePath(), {
|
|
19
|
+
meta: {},
|
|
20
|
+
signals: [],
|
|
21
|
+
decisions: [],
|
|
22
|
+
telemetryOutbox: [],
|
|
23
|
+
});
|
|
24
|
+
store.openDatabase();
|
|
25
|
+
return store;
|
|
26
|
+
}
|
|
27
|
+
get usingJsonFallback() {
|
|
28
|
+
return this.jsonMode;
|
|
29
|
+
}
|
|
30
|
+
close() {
|
|
31
|
+
this.db?.close();
|
|
32
|
+
}
|
|
33
|
+
migrate() {
|
|
34
|
+
if (this.db) {
|
|
35
|
+
this.db.exec(`
|
|
36
|
+
create table if not exists meta (
|
|
37
|
+
key text primary key,
|
|
38
|
+
value text not null
|
|
39
|
+
);
|
|
40
|
+
create table if not exists sessions (
|
|
41
|
+
session_id text primary key,
|
|
42
|
+
started_at text not null,
|
|
43
|
+
ended_at text,
|
|
44
|
+
host_type text,
|
|
45
|
+
repo_root text,
|
|
46
|
+
metadata_json text not null default '{}'
|
|
47
|
+
);
|
|
48
|
+
create table if not exists signals (
|
|
49
|
+
id integer primary key autoincrement,
|
|
50
|
+
session_id text not null,
|
|
51
|
+
sequence integer not null,
|
|
52
|
+
kind text not null,
|
|
53
|
+
summary text not null,
|
|
54
|
+
evidence_json text not null default '[]',
|
|
55
|
+
files_json text not null default '[]',
|
|
56
|
+
commands_json text not null default '[]',
|
|
57
|
+
metadata_json text not null default '{}',
|
|
58
|
+
captured_at text not null,
|
|
59
|
+
uploaded_at text,
|
|
60
|
+
unique(session_id, sequence)
|
|
61
|
+
);
|
|
62
|
+
create table if not exists decisions (
|
|
63
|
+
id text primary key,
|
|
64
|
+
session_id text,
|
|
65
|
+
headline text not null,
|
|
66
|
+
why text,
|
|
67
|
+
status text not null default 'proposed',
|
|
68
|
+
alignment text,
|
|
69
|
+
files_json text not null default '[]',
|
|
70
|
+
source_signal_ids text not null default '[]',
|
|
71
|
+
raw_content text not null,
|
|
72
|
+
created_at text not null,
|
|
73
|
+
updated_at text not null,
|
|
74
|
+
uploaded_at text
|
|
75
|
+
);
|
|
76
|
+
create table if not exists telemetry_outbox (
|
|
77
|
+
id integer primary key autoincrement,
|
|
78
|
+
payload_json text not null,
|
|
79
|
+
created_at text not null,
|
|
80
|
+
attempts integer not null default 0,
|
|
81
|
+
last_attempt_at text,
|
|
82
|
+
delivered_at text
|
|
83
|
+
);
|
|
84
|
+
`);
|
|
85
|
+
this.setMeta("schema_version", "1");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.data.meta.schema_version = "1";
|
|
89
|
+
this.persistJson();
|
|
90
|
+
}
|
|
91
|
+
getMeta(key) {
|
|
92
|
+
if (this.db) {
|
|
93
|
+
return String(this.db.prepare("select value from meta where key = ?").get(key)?.value ?? "");
|
|
94
|
+
}
|
|
95
|
+
return this.data.meta[key] ?? "";
|
|
96
|
+
}
|
|
97
|
+
setMeta(key, value) {
|
|
98
|
+
if (this.db) {
|
|
99
|
+
this.db
|
|
100
|
+
.prepare("insert into meta (key, value) values (?, ?) on conflict(key) do update set value = excluded.value")
|
|
101
|
+
.run(key, value);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.data.meta[key] = value;
|
|
105
|
+
this.persistJson();
|
|
106
|
+
}
|
|
107
|
+
insertSignal(input) {
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
if (this.db) {
|
|
110
|
+
const existing = this.db
|
|
111
|
+
.prepare("select * from signals where session_id = ? and sequence = ?")
|
|
112
|
+
.get(input.sessionId, input.sequence);
|
|
113
|
+
if (existing) {
|
|
114
|
+
return rowToSignal(existing);
|
|
115
|
+
}
|
|
116
|
+
const result = this.db
|
|
117
|
+
.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);
|
|
121
|
+
const id = Number(result.lastInsertRowid ?? 0);
|
|
122
|
+
return this.getSignal(id);
|
|
123
|
+
}
|
|
124
|
+
const existing = this.data.signals.find((signal) => signal.sessionId === input.sessionId && signal.sequence === input.sequence);
|
|
125
|
+
if (existing) {
|
|
126
|
+
return existing;
|
|
127
|
+
}
|
|
128
|
+
const signal = {
|
|
129
|
+
id: this.nextJsonId(this.data.signals),
|
|
130
|
+
sessionId: input.sessionId,
|
|
131
|
+
sequence: input.sequence,
|
|
132
|
+
kind: input.kind,
|
|
133
|
+
summary: input.summary,
|
|
134
|
+
evidence: input.evidence ?? [],
|
|
135
|
+
filesTouched: input.filesTouched ?? [],
|
|
136
|
+
commandsRun: input.commandsRun ?? [],
|
|
137
|
+
metadata: input.metadata ?? {},
|
|
138
|
+
capturedAt: now,
|
|
139
|
+
};
|
|
140
|
+
this.data.signals.push(signal);
|
|
141
|
+
this.persistJson();
|
|
142
|
+
return signal;
|
|
143
|
+
}
|
|
144
|
+
listSignals(input = {}) {
|
|
145
|
+
const limit = input.limit ?? 300;
|
|
146
|
+
if (this.db) {
|
|
147
|
+
const where = input.sessionId ? "where session_id = ?" : "";
|
|
148
|
+
const rows = this.db
|
|
149
|
+
.prepare(`select * from signals ${where} order by captured_at asc, id asc limit ?`)
|
|
150
|
+
.all(...(input.sessionId ? [input.sessionId, limit] : [limit]));
|
|
151
|
+
return rows.map(rowToSignal);
|
|
152
|
+
}
|
|
153
|
+
return this.data.signals
|
|
154
|
+
.filter((signal) => !input.sessionId || signal.sessionId === input.sessionId)
|
|
155
|
+
.sort((a, b) => a.capturedAt.localeCompare(b.capturedAt) || a.id - b.id)
|
|
156
|
+
.slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
getSignal(id) {
|
|
159
|
+
if (this.db) {
|
|
160
|
+
const row = this.db.prepare("select * from signals where id = ?").get(id);
|
|
161
|
+
return row ? rowToSignal(row) : null;
|
|
162
|
+
}
|
|
163
|
+
return this.data.signals.find((signal) => signal.id === id) ?? null;
|
|
164
|
+
}
|
|
165
|
+
mostRecentSessionId() {
|
|
166
|
+
const signal = this.listSignals({ limit: 1 }).at(-1);
|
|
167
|
+
if (signal) {
|
|
168
|
+
return signal.sessionId;
|
|
169
|
+
}
|
|
170
|
+
if (this.db) {
|
|
171
|
+
const row = this.db.prepare("select session_id from signals order by captured_at desc, id desc limit 1").get();
|
|
172
|
+
return typeof row?.session_id === "string" ? row.session_id : null;
|
|
173
|
+
}
|
|
174
|
+
return this.data.signals.at(-1)?.sessionId ?? null;
|
|
175
|
+
}
|
|
176
|
+
createDecision(input) {
|
|
177
|
+
const now = new Date().toISOString();
|
|
178
|
+
const id = input.id ?? createLocalId();
|
|
179
|
+
const raw = input.rawContent.trim();
|
|
180
|
+
const decision = {
|
|
181
|
+
id,
|
|
182
|
+
sessionId: input.sessionId ?? null,
|
|
183
|
+
headline: (input.headline ?? raw.split(/\n+/)[0] ?? raw).slice(0, 80),
|
|
184
|
+
why: input.why ?? null,
|
|
185
|
+
status: input.status ?? "proposed",
|
|
186
|
+
alignment: input.alignment ?? null,
|
|
187
|
+
files: input.files ?? [],
|
|
188
|
+
sourceSignalIds: input.sourceSignalIds ?? [],
|
|
189
|
+
rawContent: raw,
|
|
190
|
+
createdAt: now,
|
|
191
|
+
updatedAt: now,
|
|
192
|
+
uploadedAt: null,
|
|
193
|
+
};
|
|
194
|
+
if (this.db) {
|
|
195
|
+
this.db
|
|
196
|
+
.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);
|
|
200
|
+
return decision;
|
|
201
|
+
}
|
|
202
|
+
this.data.decisions.push(decision);
|
|
203
|
+
this.persistJson();
|
|
204
|
+
return decision;
|
|
205
|
+
}
|
|
206
|
+
updateDecision(id, patch) {
|
|
207
|
+
const existing = this.getDecision(id);
|
|
208
|
+
if (!existing) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const next = {
|
|
212
|
+
...existing,
|
|
213
|
+
...patch,
|
|
214
|
+
updatedAt: new Date().toISOString(),
|
|
215
|
+
};
|
|
216
|
+
if (this.db) {
|
|
217
|
+
this.db
|
|
218
|
+
.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);
|
|
221
|
+
return next;
|
|
222
|
+
}
|
|
223
|
+
this.data.decisions = this.data.decisions.map((decision) => (decision.id === id ? next : decision));
|
|
224
|
+
this.persistJson();
|
|
225
|
+
return next;
|
|
226
|
+
}
|
|
227
|
+
deleteDecision(id) {
|
|
228
|
+
if (this.db) {
|
|
229
|
+
return Number(this.db.prepare("delete from decisions where id = ?").run(id).changes ?? 0) > 0;
|
|
230
|
+
}
|
|
231
|
+
const before = this.data.decisions.length;
|
|
232
|
+
this.data.decisions = this.data.decisions.filter((decision) => decision.id !== id);
|
|
233
|
+
this.persistJson();
|
|
234
|
+
return this.data.decisions.length !== before;
|
|
235
|
+
}
|
|
236
|
+
getDecision(id) {
|
|
237
|
+
if (this.db) {
|
|
238
|
+
const row = this.db.prepare("select * from decisions where id = ?").get(id);
|
|
239
|
+
return row ? rowToDecision(row) : null;
|
|
240
|
+
}
|
|
241
|
+
return this.data.decisions.find((decision) => decision.id === id) ?? null;
|
|
242
|
+
}
|
|
243
|
+
listDecisions(input = {}) {
|
|
244
|
+
const limit = input.limit ?? 50;
|
|
245
|
+
if (this.db) {
|
|
246
|
+
const clauses = [];
|
|
247
|
+
const params = [];
|
|
248
|
+
if (input.status) {
|
|
249
|
+
clauses.push("status = ?");
|
|
250
|
+
params.push(input.status);
|
|
251
|
+
}
|
|
252
|
+
if (input.since) {
|
|
253
|
+
clauses.push("created_at >= ?");
|
|
254
|
+
params.push(input.since);
|
|
255
|
+
}
|
|
256
|
+
if (input.pendingUploadOnly) {
|
|
257
|
+
clauses.push("uploaded_at is null");
|
|
258
|
+
}
|
|
259
|
+
const where = clauses.length > 0 ? `where ${clauses.join(" and ")}` : "";
|
|
260
|
+
return this.db
|
|
261
|
+
.prepare(`select * from decisions ${where} order by created_at desc limit ?`)
|
|
262
|
+
.all(...params, limit)
|
|
263
|
+
.map(rowToDecision);
|
|
264
|
+
}
|
|
265
|
+
return this.data.decisions
|
|
266
|
+
.filter((decision) => !input.status || decision.status === input.status)
|
|
267
|
+
.filter((decision) => !input.since || decision.createdAt >= input.since)
|
|
268
|
+
.filter((decision) => !input.pendingUploadOnly || !decision.uploadedAt)
|
|
269
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
270
|
+
.slice(0, limit);
|
|
271
|
+
}
|
|
272
|
+
enqueueTelemetry(payload) {
|
|
273
|
+
const now = new Date().toISOString();
|
|
274
|
+
if (this.db) {
|
|
275
|
+
const result = this.db
|
|
276
|
+
.prepare("insert into telemetry_outbox (payload_json, created_at) values (?, ?)")
|
|
277
|
+
.run(JSON.stringify(payload), now);
|
|
278
|
+
return Number(result.lastInsertRowid ?? 0);
|
|
279
|
+
}
|
|
280
|
+
const id = this.nextJsonId(this.data.telemetryOutbox);
|
|
281
|
+
this.data.telemetryOutbox.push({
|
|
282
|
+
id,
|
|
283
|
+
payload,
|
|
284
|
+
createdAt: now,
|
|
285
|
+
attempts: 0,
|
|
286
|
+
lastAttemptAt: null,
|
|
287
|
+
deliveredAt: null,
|
|
288
|
+
});
|
|
289
|
+
this.persistJson();
|
|
290
|
+
return id;
|
|
291
|
+
}
|
|
292
|
+
listTelemetryOutbox(input = {}) {
|
|
293
|
+
const limit = input.limit ?? 20;
|
|
294
|
+
if (this.db) {
|
|
295
|
+
const where = input.undeliveredOnly ? "where delivered_at is null" : "";
|
|
296
|
+
return this.db
|
|
297
|
+
.prepare(`select * from telemetry_outbox ${where} order by id asc limit ?`)
|
|
298
|
+
.all(limit)
|
|
299
|
+
.map(rowToTelemetry);
|
|
300
|
+
}
|
|
301
|
+
return this.data.telemetryOutbox
|
|
302
|
+
.filter((row) => !input.undeliveredOnly || !row.deliveredAt)
|
|
303
|
+
.sort((a, b) => a.id - b.id)
|
|
304
|
+
.slice(0, limit);
|
|
305
|
+
}
|
|
306
|
+
markTelemetryAttempt(id, delivered) {
|
|
307
|
+
const now = new Date().toISOString();
|
|
308
|
+
if (this.db) {
|
|
309
|
+
this.db
|
|
310
|
+
.prepare(`update telemetry_outbox
|
|
311
|
+
set attempts = attempts + 1, last_attempt_at = ?, delivered_at = case when ? then ? else delivered_at end
|
|
312
|
+
where id = ?`)
|
|
313
|
+
.run(now, delivered ? 1 : 0, now, id);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.data.telemetryOutbox = this.data.telemetryOutbox.map((row) => row.id === id
|
|
317
|
+
? {
|
|
318
|
+
...row,
|
|
319
|
+
attempts: row.attempts + 1,
|
|
320
|
+
lastAttemptAt: now,
|
|
321
|
+
deliveredAt: delivered ? now : row.deliveredAt,
|
|
322
|
+
}
|
|
323
|
+
: row);
|
|
324
|
+
this.persistJson();
|
|
325
|
+
}
|
|
326
|
+
stats() {
|
|
327
|
+
const signals = this.listSignals({ limit: 100000 });
|
|
328
|
+
const decisions = this.listDecisions({ limit: 100000 });
|
|
329
|
+
return {
|
|
330
|
+
signals: signals.length,
|
|
331
|
+
decisions: decisions.length,
|
|
332
|
+
decisionsByStatus: decisions.reduce((accumulator, decision) => {
|
|
333
|
+
accumulator[decision.status] = (accumulator[decision.status] ?? 0) + 1;
|
|
334
|
+
return accumulator;
|
|
335
|
+
}, {}),
|
|
336
|
+
lastSessionId: signals.at(-1)?.sessionId ?? null,
|
|
337
|
+
jsonFallback: this.usingJsonFallback,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
openDatabase() {
|
|
341
|
+
const loaded = tryRequireBetterSqlite3();
|
|
342
|
+
if (loaded) {
|
|
343
|
+
fs.mkdirSync(path.dirname(this.storePath), { recursive: true, mode: 0o700 });
|
|
344
|
+
this.db = new loaded(this.storePath);
|
|
345
|
+
this.migrate();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
this.jsonMode = true;
|
|
349
|
+
this.jsonPath = jsonFallbackStorePath();
|
|
350
|
+
this.data = readJsonFile(this.jsonPath) ?? this.data;
|
|
351
|
+
this.migrate();
|
|
352
|
+
}
|
|
353
|
+
persistJson() {
|
|
354
|
+
writePrivateJson(this.jsonPath, this.data);
|
|
355
|
+
}
|
|
356
|
+
nextJsonId(rows) {
|
|
357
|
+
return rows.reduce((max, row) => Math.max(max, row.id), 0) + 1;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function tryRequireBetterSqlite3() {
|
|
361
|
+
try {
|
|
362
|
+
// createRequire keeps the CLI startup synchronous while still letting unsupported platforms fall back cleanly.
|
|
363
|
+
const req = createRequire(import.meta.url);
|
|
364
|
+
return req("better-sqlite3");
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function parseJson(value, fallback) {
|
|
371
|
+
if (typeof value !== "string") {
|
|
372
|
+
return fallback;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
return JSON.parse(value);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return fallback;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function rowToSignal(row) {
|
|
382
|
+
return {
|
|
383
|
+
id: Number(row.id),
|
|
384
|
+
sessionId: String(row.session_id),
|
|
385
|
+
sequence: Number(row.sequence),
|
|
386
|
+
kind: row.kind,
|
|
387
|
+
summary: String(row.summary),
|
|
388
|
+
evidence: parseJson(row.evidence_json, []),
|
|
389
|
+
filesTouched: parseJson(row.files_json, []),
|
|
390
|
+
commandsRun: parseJson(row.commands_json, []),
|
|
391
|
+
metadata: parseJson(row.metadata_json, {}),
|
|
392
|
+
capturedAt: String(row.captured_at),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function rowToDecision(row) {
|
|
396
|
+
return {
|
|
397
|
+
id: String(row.id),
|
|
398
|
+
sessionId: typeof row.session_id === "string" ? row.session_id : null,
|
|
399
|
+
headline: String(row.headline),
|
|
400
|
+
why: typeof row.why === "string" ? row.why : null,
|
|
401
|
+
status: row.status,
|
|
402
|
+
alignment: typeof row.alignment === "string" ? row.alignment : null,
|
|
403
|
+
files: parseJson(row.files_json, []),
|
|
404
|
+
sourceSignalIds: parseJson(row.source_signal_ids, []),
|
|
405
|
+
rawContent: String(row.raw_content),
|
|
406
|
+
createdAt: String(row.created_at),
|
|
407
|
+
updatedAt: String(row.updated_at),
|
|
408
|
+
uploadedAt: typeof row.uploaded_at === "string" ? row.uploaded_at : null,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function rowToTelemetry(row) {
|
|
412
|
+
return {
|
|
413
|
+
id: Number(row.id),
|
|
414
|
+
payload: parseJson(row.payload_json, {}),
|
|
415
|
+
createdAt: String(row.created_at),
|
|
416
|
+
attempts: Number(row.attempts),
|
|
417
|
+
lastAttemptAt: typeof row.last_attempt_at === "string" ? row.last_attempt_at : null,
|
|
418
|
+
deliveredAt: typeof row.delivered_at === "string" ? row.delivered_at : null,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function createLocalId() {
|
|
422
|
+
return `d_${crypto.randomBytes(8).toString("hex")}`;
|
|
423
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
export async function waitForLoopbackToken(input = {}) {
|
|
3
|
+
const timeoutMs = input.timeoutMs ?? 120_000;
|
|
4
|
+
const callbackPath = input.path ?? "/auth-callback";
|
|
5
|
+
const tokenParam = input.tokenParam ?? "token";
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const server = http.createServer((request, response) => {
|
|
8
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
9
|
+
if (url.pathname !== callbackPath) {
|
|
10
|
+
response.writeHead(404).end("Not found");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const token = url.searchParams.get(tokenParam)?.trim();
|
|
14
|
+
if (!token) {
|
|
15
|
+
response.writeHead(400).end("Missing token");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
response.writeHead(200, { "Content-Type": "text/plain" }).end("Ask The W upgrade authorized. You can return to your terminal.");
|
|
19
|
+
server.close();
|
|
20
|
+
resolve({ token, port: Number(server.address()?.port ?? 0) });
|
|
21
|
+
});
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
server.close();
|
|
24
|
+
reject(new Error("Timed out waiting for browser authorization."));
|
|
25
|
+
}, timeoutMs);
|
|
26
|
+
server.once("close", () => clearTimeout(timer));
|
|
27
|
+
server.once("error", reject);
|
|
28
|
+
server.listen(0, "127.0.0.1");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function askTheWDataDir(env?: NodeJS.ProcessEnv): string;
|
|
2
|
+
export declare function ensureAskTheWDataDir(env?: NodeJS.ProcessEnv): string;
|
|
3
|
+
export declare function localStorePath(env?: NodeJS.ProcessEnv): string;
|
|
4
|
+
export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
|
|
5
|
+
export declare function jsonFallbackStorePath(env?: NodeJS.ProcessEnv): string;
|
|
6
|
+
export declare function writePrivateJson(filePath: string, value: unknown): void;
|
|
7
|
+
export declare function readJsonFile<T>(filePath: string): T | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function askTheWDataDir(env = process.env) {
|
|
5
|
+
const explicit = env.ASKTHEW_DATA_DIR?.trim();
|
|
6
|
+
if (explicit) {
|
|
7
|
+
return path.resolve(explicit);
|
|
8
|
+
}
|
|
9
|
+
const xdgDataHome = env.XDG_DATA_HOME?.trim();
|
|
10
|
+
if (xdgDataHome) {
|
|
11
|
+
return path.join(path.resolve(xdgDataHome), "askthew");
|
|
12
|
+
}
|
|
13
|
+
return path.join(os.homedir(), ".askthew");
|
|
14
|
+
}
|
|
15
|
+
export function ensureAskTheWDataDir(env = process.env) {
|
|
16
|
+
const dir = askTheWDataDir(env);
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
export function localStorePath(env = process.env) {
|
|
21
|
+
return path.join(askTheWDataDir(env), "store.sqlite");
|
|
22
|
+
}
|
|
23
|
+
export function credentialsPath(env = process.env) {
|
|
24
|
+
return path.join(askTheWDataDir(env), "credentials.json");
|
|
25
|
+
}
|
|
26
|
+
export function jsonFallbackStorePath(env = process.env) {
|
|
27
|
+
return path.join(askTheWDataDir(env), "store.json");
|
|
28
|
+
}
|
|
29
|
+
export function writePrivateJson(filePath, value) {
|
|
30
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
31
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
32
|
+
fs.chmodSync(filePath, 0o600);
|
|
33
|
+
}
|
|
34
|
+
export function readJsonFile(filePath) {
|
|
35
|
+
if (!fs.existsSync(filePath)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type CliCredentials } from "./free-tier-policy.js";
|
|
2
|
+
import type { LocalStore } from "./local-store.js";
|
|
3
|
+
export declare function buildTelemetryPayload(input: {
|
|
4
|
+
store: LocalStore;
|
|
5
|
+
credentials: CliCredentials;
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
toolUsage?: Record<string, number>;
|
|
8
|
+
now?: Date;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
}): unknown;
|
|
11
|
+
export declare function flushTelemetryOutbox(input: {
|
|
12
|
+
store: LocalStore;
|
|
13
|
+
credentials: CliCredentials;
|
|
14
|
+
apiUrl?: string;
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
ok: boolean;
|
|
19
|
+
skipped: boolean;
|
|
20
|
+
sent: number;
|
|
21
|
+
} | {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
sent: number;
|
|
24
|
+
skipped?: undefined;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { resolvePluginScope } from "../scope.js";
|
|
3
|
+
import { isTelemetryOptedOut } from "./free-tier-policy.js";
|
|
4
|
+
export function buildTelemetryPayload(input) {
|
|
5
|
+
const now = input.now ?? new Date();
|
|
6
|
+
const sessionId = input.sessionId ?? input.store.mostRecentSessionId() ?? "unknown";
|
|
7
|
+
const signals = input.store.listSignals({ sessionId, limit: 100000 });
|
|
8
|
+
const decisions = input.store.listDecisions({ limit: 100000 });
|
|
9
|
+
const stack = detectStack(input.cwd ?? process.cwd());
|
|
10
|
+
return stripTelemetry({
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
userId: input.credentials.userId,
|
|
13
|
+
cliTokenId: input.credentials.cliTokenId,
|
|
14
|
+
sentAt: now.toISOString(),
|
|
15
|
+
session: {
|
|
16
|
+
id: hashSessionId(input.credentials.userId, sessionId),
|
|
17
|
+
startedAt: signals[0]?.capturedAt ?? now.toISOString(),
|
|
18
|
+
endedAt: signals.at(-1)?.capturedAt ?? now.toISOString(),
|
|
19
|
+
durationMs: durationMs(signals),
|
|
20
|
+
hostType: String(signals.at(-1)?.metadata.hostType ?? signals.at(-1)?.metadata.host_type ?? "unknown"),
|
|
21
|
+
clientId: String(signals.at(-1)?.metadata.client_id ?? "unknown"),
|
|
22
|
+
},
|
|
23
|
+
stack,
|
|
24
|
+
activity: activityStats(signals),
|
|
25
|
+
decisions: decisionStats(decisions),
|
|
26
|
+
tools: input.toolUsage ?? {},
|
|
27
|
+
plugin: {
|
|
28
|
+
version: "0.4.0",
|
|
29
|
+
platform: `${process.platform}-${process.arch}`,
|
|
30
|
+
node: process.version.replace(/^v/, ""),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export async function flushTelemetryOutbox(input) {
|
|
35
|
+
if (isTelemetryOptedOut(input.env, input.credentials)) {
|
|
36
|
+
return { ok: true, skipped: true, sent: 0 };
|
|
37
|
+
}
|
|
38
|
+
const fetcher = input.fetchImpl ?? fetch;
|
|
39
|
+
const apiUrl = (input.apiUrl ?? input.credentials.apiUrl ?? process.env.ASKTHEW_API_URL ?? "https://app.askthew.com").replace(/\/$/, "");
|
|
40
|
+
let sent = 0;
|
|
41
|
+
for (const row of input.store.listTelemetryOutbox({ undeliveredOnly: true, limit: 20 })) {
|
|
42
|
+
const response = await fetcher(`${apiUrl}/api/cli/v1/telemetry`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
Authorization: `Bearer ${input.credentials.cliToken}`,
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(row.payload),
|
|
49
|
+
}).catch(() => null);
|
|
50
|
+
const delivered = Boolean(response?.ok);
|
|
51
|
+
input.store.markTelemetryAttempt(row.id, delivered);
|
|
52
|
+
if (delivered) {
|
|
53
|
+
sent += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, sent };
|
|
57
|
+
}
|
|
58
|
+
function hashSessionId(userId, sessionId) {
|
|
59
|
+
return crypto.createHash("sha256").update(`${userId}:${sessionId}`).digest("hex");
|
|
60
|
+
}
|
|
61
|
+
function durationMs(signals) {
|
|
62
|
+
if (signals.length < 2) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
return Math.max(0, new Date(signals.at(-1).capturedAt).getTime() - new Date(signals[0].capturedAt).getTime());
|
|
66
|
+
}
|
|
67
|
+
function activityStats(signals) {
|
|
68
|
+
const counts = {};
|
|
69
|
+
let pass = 0;
|
|
70
|
+
let fail = 0;
|
|
71
|
+
let unknown = 0;
|
|
72
|
+
for (const signal of signals) {
|
|
73
|
+
counts[signal.kind] = (counts[signal.kind] ?? 0) + 1;
|
|
74
|
+
if (signal.kind === "verification_result") {
|
|
75
|
+
if (/pass|green|ok|success/i.test(signal.summary)) {
|
|
76
|
+
pass += 1;
|
|
77
|
+
}
|
|
78
|
+
else if (/fail|red|error|broken|timeout/i.test(signal.summary)) {
|
|
79
|
+
fail += 1;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
unknown += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
signalCount: signals.length,
|
|
88
|
+
signalCountsByKind: counts,
|
|
89
|
+
filesTouchedCount: new Set(signals.flatMap((signal) => signal.filesTouched.map((file) => extensionOnly(file)))).size,
|
|
90
|
+
commandsRunCount: signals.reduce((total, signal) => total + signal.commandsRun.length, 0),
|
|
91
|
+
verification: { pass, fail, unknown },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function decisionStats(decisions) {
|
|
95
|
+
const byStatus = {};
|
|
96
|
+
for (const decision of decisions) {
|
|
97
|
+
byStatus[decision.status] = (byStatus[decision.status] ?? 0) + 1;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
createdCount: decisions.length,
|
|
101
|
+
byStatus,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function detectStack(cwd) {
|
|
105
|
+
const scope = resolvePluginScope(cwd);
|
|
106
|
+
const packageManagers = scope.repoName ? ["npm"] : [];
|
|
107
|
+
return {
|
|
108
|
+
languages: ["typescript"],
|
|
109
|
+
extensions: [".ts", ".tsx", ".sql"],
|
|
110
|
+
frameworks: ["next", "supabase"].filter(Boolean),
|
|
111
|
+
packageManagers,
|
|
112
|
+
monorepo: scope.appPath ? "turborepo" : "unknown",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function extensionOnly(file) {
|
|
116
|
+
const match = file.match(/(\.[A-Za-z0-9]+)$/);
|
|
117
|
+
return match?.[1] ?? "";
|
|
118
|
+
}
|
|
119
|
+
function stripTelemetry(value) {
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
if (value.includes("/") && !value.startsWith(".")) {
|
|
122
|
+
return "[redacted]";
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(value)) {
|
|
127
|
+
return value.map(stripTelemetry);
|
|
128
|
+
}
|
|
129
|
+
if (value && typeof value === "object") {
|
|
130
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, stripTelemetry(entry)]));
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LocalDecision, LocalSignal } from "./local-store.js";
|
|
2
|
+
export type TipSeverity = "high" | "medium" | "low";
|
|
3
|
+
export interface SessionTip {
|
|
4
|
+
id: string;
|
|
5
|
+
severity: TipSeverity;
|
|
6
|
+
tip: string;
|
|
7
|
+
alternative: string;
|
|
8
|
+
evidence: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface TipEngineInput {
|
|
11
|
+
signals: LocalSignal[];
|
|
12
|
+
decisions: LocalDecision[];
|
|
13
|
+
now?: Date;
|
|
14
|
+
}
|
|
15
|
+
type Pattern = (input: TipEngineInput) => SessionTip | null;
|
|
16
|
+
export declare function analyzeLocalPatterns(input: TipEngineInput): SessionTip[];
|
|
17
|
+
export declare const TIP_PATTERNS: Pattern[];
|
|
18
|
+
export {};
|