whydid 1.0.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 +232 -0
- package/dashboard/dist/android-chrome-192x192.png +0 -0
- package/dashboard/dist/android-chrome-512x512.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/index-DImKqodm.js +69 -0
- package/dashboard/dist/assets/index-mHXgqqkj.css +1 -0
- package/dashboard/dist/favicon-16x16.png +0 -0
- package/dashboard/dist/favicon-32x32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/index.html +24 -0
- package/dashboard/dist/logo-mark.svg +8 -0
- package/dashboard/dist/site.webmanifest +11 -0
- package/dist/adapters/claude-code.js +168 -0
- package/dist/adapters/hook-events.js +86 -0
- package/dist/adapters/sync-claude-code.js +85 -0
- package/dist/cli/ai-budget-warning.js +19 -0
- package/dist/cli/commands/agent-event.js +80 -0
- package/dist/cli/commands/checkpoint.js +36 -0
- package/dist/cli/commands/context.js +27 -0
- package/dist/cli/commands/daemon.js +139 -0
- package/dist/cli/commands/export.js +36 -0
- package/dist/cli/commands/init.js +63 -0
- package/dist/cli/commands/key.js +65 -0
- package/dist/cli/commands/license.js +57 -0
- package/dist/cli/commands/milestone.js +64 -0
- package/dist/cli/commands/prompt.js +75 -0
- package/dist/cli/commands/recap.js +48 -0
- package/dist/cli/commands/restore-fork.js +101 -0
- package/dist/cli/commands/status.js +26 -0
- package/dist/cli/commands/test.js +33 -0
- package/dist/cli/commands/timeline.js +23 -0
- package/dist/cli/commands/why.js +71 -0
- package/dist/cli/context.js +48 -0
- package/dist/cli/index.js +77 -0
- package/dist/cli/license-gate.js +26 -0
- package/dist/cli/preflight.js +28 -0
- package/dist/core/agent-context.js +46 -0
- package/dist/core/agent-hooks.js +68 -0
- package/dist/core/ai-budget.js +27 -0
- package/dist/core/chat-retrieval.js +36 -0
- package/dist/core/context-tree.js +135 -0
- package/dist/core/diff.js +30 -0
- package/dist/core/engine.js +321 -0
- package/dist/core/export.js +98 -0
- package/dist/core/file-history.js +31 -0
- package/dist/core/manifest.js +102 -0
- package/dist/core/recap.js +49 -0
- package/dist/core/test-runner.js +23 -0
- package/dist/daemon/listen.js +26 -0
- package/dist/daemon/pidfile.js +36 -0
- package/dist/daemon/server.js +554 -0
- package/dist/db/database.js +631 -0
- package/dist/db/schema.js +186 -0
- package/dist/git/git.js +22 -0
- package/dist/licensing/config.js +28 -0
- package/dist/licensing/license-client.js +45 -0
- package/dist/licensing/license-manager.js +89 -0
- package/dist/licensing/license-payload.js +12 -0
- package/dist/licensing/license-store.js +96 -0
- package/dist/licensing/machine-id.js +78 -0
- package/dist/licensing/secret-store.js +102 -0
- package/dist/licensing/verify-license.js +20 -0
- package/dist/llm/agent-prompt.js +36 -0
- package/dist/llm/annotate-checkpoint.js +67 -0
- package/dist/llm/annotate.js +11 -0
- package/dist/llm/anthropic-provider.js +97 -0
- package/dist/llm/chat.js +35 -0
- package/dist/llm/community-pricing.js +75 -0
- package/dist/llm/factory.js +17 -0
- package/dist/llm/http-error.js +25 -0
- package/dist/llm/noop-provider.js +19 -0
- package/dist/llm/openai-provider.js +85 -0
- package/dist/llm/pricing.js +55 -0
- package/dist/llm/prompt.js +103 -0
- package/dist/llm/provider.js +2 -0
- package/dist/llm/schema.js +49 -0
- package/dist/llm/social-post.js +49 -0
- package/dist/objects/store.js +31 -0
- package/dist/shared/config.js +45 -0
- package/dist/shared/redaction.js +18 -0
- package/dist/shared/types.js +2 -0
- package/dist/watcher/ignore-rules.js +92 -0
- package/dist/watcher/watcher.js +249 -0
- package/package.json +55 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function describeEntry(entry) {
|
|
2
|
+
return entry.turnPrompt ?? entry.annotationSummary ?? entry.stateLabel ?? entry.stateType;
|
|
3
|
+
}
|
|
4
|
+
export function buildAgentPromptQuestion(req) {
|
|
5
|
+
const laterSummary = req.laterEntries.length > 0
|
|
6
|
+
? req.laterEntries.map((e, i) => `${i + 1}. ${describeEntry(e)} (state #${e.stateId.slice(0, 8)})`).join("\n")
|
|
7
|
+
: "(nothing else has changed this file since)";
|
|
8
|
+
const question = req.mode === "revert"
|
|
9
|
+
? `Write a clear, ready-to-paste prompt I can give to my AI coding agent (like Claude Code) to REVERT ONLY this specific change to ${req.filePath}, without disturbing the later changes listed below that were built on top of it. If a clean revert isn't possible without touching that later work, say so plainly in the generated prompt and describe what to check instead of pretending it's safe.`
|
|
10
|
+
: `Write a clear, ready-to-paste prompt I can give to my AI coding agent (like Claude Code) to build on top of this specific past change to ${req.filePath}: "${req.continueInstruction}". The prompt must make the agent aware of the later changes listed below so it doesn't conflict with or duplicate that work.`;
|
|
11
|
+
const context = [
|
|
12
|
+
`File: ${req.filePath}`,
|
|
13
|
+
"",
|
|
14
|
+
`Original change I want to ${req.mode === "revert" ? "revert" : "build on"} (state #${req.targetEntry.stateId.slice(0, 8)}):`,
|
|
15
|
+
`Prompt that caused it: ${req.targetEntry.turnPrompt ?? "(unknown — no session context)"}`,
|
|
16
|
+
`Summary: ${req.targetEntry.annotationSummary ?? "(unknown — no AI annotation)"}`,
|
|
17
|
+
"Diff:",
|
|
18
|
+
req.targetDiff,
|
|
19
|
+
"",
|
|
20
|
+
"Changes to this same file that happened AFTER this one:",
|
|
21
|
+
laterSummary,
|
|
22
|
+
].join("\n");
|
|
23
|
+
return { question, context };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Asks the configured provider to synthesize a ready-to-paste prompt for the
|
|
27
|
+
* user's own AI coding agent. Throws on provider failure — callers decide
|
|
28
|
+
* how to surface that (CLI prints it, the API returns 502), since unlike
|
|
29
|
+
* annotation this is a synchronous, user-requested action with no local
|
|
30
|
+
* fallback to degrade to.
|
|
31
|
+
*/
|
|
32
|
+
export async function generateAgentPrompt(provider, req) {
|
|
33
|
+
const { question, context } = buildAgentPromptQuestion(req);
|
|
34
|
+
return provider.answerQuestion(question, context);
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=agent-prompt.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { computeFileDiff } from "../core/diff.js";
|
|
3
|
+
import { annotateState } from "./annotate.js";
|
|
4
|
+
const MAX_DIFF_CHARS = 12000;
|
|
5
|
+
function buildDiffText(ctx, fileChanges) {
|
|
6
|
+
const parts = [];
|
|
7
|
+
for (const change of fileChanges) {
|
|
8
|
+
if (change.changeType === "deleted") {
|
|
9
|
+
parts.push(`--- ${change.filePath} (deleted) ---`);
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const diff = computeFileDiff(ctx.store, change);
|
|
13
|
+
parts.push(diff.binary ? `--- ${change.filePath} (binary, diff omitted) ---` : diff.unified);
|
|
14
|
+
}
|
|
15
|
+
const joined = parts.join("\n\n");
|
|
16
|
+
return joined.length > MAX_DIFF_CHARS ? `${joined.slice(0, MAX_DIFF_CHARS)}\n... (diff truncated)` : joined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Generates and persists an annotation for a checkpoint. Never throws —
|
|
20
|
+
* a failed or unavailable LLM call must never take down the checkpoint
|
|
21
|
+
* that already succeeded locally.
|
|
22
|
+
*/
|
|
23
|
+
export async function annotateCheckpoint(ctx, provider, stateId, fileChanges, opts = {}) {
|
|
24
|
+
if (provider.id === "noop" || fileChanges.length === 0) {
|
|
25
|
+
return { status: "unavailable" };
|
|
26
|
+
}
|
|
27
|
+
const sendPrompts = opts.sendPrompts ?? true;
|
|
28
|
+
const sendDiffs = opts.sendDiffs ?? true;
|
|
29
|
+
try {
|
|
30
|
+
const state = ctx.db.getState(stateId);
|
|
31
|
+
const parentSummary = state?.parentStateId ? ctx.db.getAnnotation(state.parentStateId)?.summary : undefined;
|
|
32
|
+
// Ground truth beats inference: if the state is linked to a real turn
|
|
33
|
+
// (from a session adapter like Claude Code) and the caller didn't pass
|
|
34
|
+
// an explicit instruction, use the turn's own recorded prompt.
|
|
35
|
+
const rawInstruction = opts.userInstruction ?? (state?.turnId ? ctx.db.getTurn(state.turnId)?.prompt ?? undefined : undefined);
|
|
36
|
+
const result = await annotateState(provider, {
|
|
37
|
+
summaryContext: parentSummary ? `Previous change: ${parentSummary}` : "",
|
|
38
|
+
diffText: sendDiffs ? buildDiffText(ctx, fileChanges) : "(diff withheld by privacy settings — file names and prompt only)",
|
|
39
|
+
filesOfInterest: fileChanges.map((c) => c.filePath),
|
|
40
|
+
userInstruction: sendPrompts ? rawInstruction : undefined,
|
|
41
|
+
});
|
|
42
|
+
ctx.db.insertAnnotation({
|
|
43
|
+
id: randomUUID(),
|
|
44
|
+
stateId,
|
|
45
|
+
summary: result.status === "generated" ? result.summary : null,
|
|
46
|
+
reason: result.status === "generated" ? result.reason : null,
|
|
47
|
+
decisions: result.status === "generated" ? result.decisions : [],
|
|
48
|
+
riskLevel: result.status === "generated" ? result.riskLevel : null,
|
|
49
|
+
generatedBy: provider.id,
|
|
50
|
+
model: result.model ?? null,
|
|
51
|
+
status: result.status,
|
|
52
|
+
inputTokens: result.usage?.inputTokens ?? null,
|
|
53
|
+
outputTokens: result.usage?.outputTokens ?? null,
|
|
54
|
+
});
|
|
55
|
+
// Feeds the project chat's retrieval step — only real, generated
|
|
56
|
+
// content is worth ever surfacing as an answer's grounding.
|
|
57
|
+
if (result.status === "generated" && state) {
|
|
58
|
+
const searchText = [result.summary, result.reason, ...result.decisions].join(" ");
|
|
59
|
+
ctx.db.indexAnnotationForSearch(stateId, state.projectId, searchText);
|
|
60
|
+
}
|
|
61
|
+
return { status: result.status };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { status: "failed" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=annotate-checkpoint.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { redactSecrets } from "../shared/redaction.js";
|
|
2
|
+
export async function annotateState(provider, input) {
|
|
3
|
+
const sanitized = { ...input, diffText: redactSecrets(input.diffText) };
|
|
4
|
+
try {
|
|
5
|
+
return await provider.generateAnnotation(sanitized);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return { summary: "", reason: "", decisions: [], riskLevel: "low", status: "failed" };
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=annotate.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { buildAnnotationPrompt, buildQuestionPrompt, buildSocialPostPrompt, buildChatPrompt } from "./prompt.js";
|
|
2
|
+
import { parseAnnotationJson, parseSocialPostJson, parseChatAnswerJson } from "./schema.js";
|
|
3
|
+
import { describeHttpError } from "./http-error.js";
|
|
4
|
+
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
|
|
5
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
6
|
+
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
7
|
+
const KNOWN_MODELS = ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"];
|
|
8
|
+
export class AnthropicProvider {
|
|
9
|
+
apiKey;
|
|
10
|
+
id = "anthropic";
|
|
11
|
+
model;
|
|
12
|
+
constructor(apiKey, model) {
|
|
13
|
+
this.apiKey = apiKey;
|
|
14
|
+
this.model = model && model.length > 0 ? model : DEFAULT_MODEL;
|
|
15
|
+
}
|
|
16
|
+
async models() {
|
|
17
|
+
return KNOWN_MODELS;
|
|
18
|
+
}
|
|
19
|
+
async complete(system, user, maxTokens) {
|
|
20
|
+
const res = await fetch(ANTHROPIC_API_URL, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
"x-api-key": this.apiKey,
|
|
24
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
model: this.model,
|
|
29
|
+
max_tokens: maxTokens,
|
|
30
|
+
system,
|
|
31
|
+
messages: [{ role: "user", content: user }],
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.text().catch(() => "");
|
|
36
|
+
throw new Error(describeHttpError("Anthropic", "ANTHROPIC_API_KEY", res.status, body));
|
|
37
|
+
}
|
|
38
|
+
const data = (await res.json());
|
|
39
|
+
const text = data.content.find((block) => block.type === "text")?.text;
|
|
40
|
+
if (!text) {
|
|
41
|
+
// Newer Claude models think adaptively by default, and thinking tokens
|
|
42
|
+
// count against max_tokens — a long reasoning pass can consume the
|
|
43
|
+
// entire budget before any text is written, leaving a response with
|
|
44
|
+
// no text block at all (a documented, real Anthropic API behavior,
|
|
45
|
+
// not something specific to any one prompt). The generous max_tokens
|
|
46
|
+
// budgets below are the primary defense; this distinguishes that
|
|
47
|
+
// specific, actionable case from a genuinely broken response instead
|
|
48
|
+
// of surfacing an opaque "no text content" message to the user.
|
|
49
|
+
if (data.stop_reason === "max_tokens") {
|
|
50
|
+
throw new Error("Anthropic's response used its entire token budget on internal reasoning before writing an answer. Try again — if it keeps happening, a shorter or simpler request may help.");
|
|
51
|
+
}
|
|
52
|
+
throw new Error("Anthropic API returned no text content");
|
|
53
|
+
}
|
|
54
|
+
return { text, usage: { inputTokens: data.usage.input_tokens, outputTokens: data.usage.output_tokens } };
|
|
55
|
+
}
|
|
56
|
+
async generateAnnotation(input) {
|
|
57
|
+
const { system, user } = buildAnnotationPrompt(input);
|
|
58
|
+
const { text, usage } = await this.complete(system, user, 4096);
|
|
59
|
+
const parsed = parseAnnotationJson(text);
|
|
60
|
+
if (!parsed) {
|
|
61
|
+
return { summary: "", reason: "", decisions: [], riskLevel: "low", status: "failed", usage, model: this.model };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
summary: parsed.summary,
|
|
65
|
+
reason: parsed.reason,
|
|
66
|
+
decisions: parsed.decisions,
|
|
67
|
+
riskLevel: parsed.risk_level,
|
|
68
|
+
status: "generated",
|
|
69
|
+
usage,
|
|
70
|
+
model: this.model,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async answerQuestion(question, context) {
|
|
74
|
+
const { system, user } = buildQuestionPrompt(question, context);
|
|
75
|
+
const { text } = await this.complete(system, user, 2048);
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
async generateSocialPost(input) {
|
|
79
|
+
const { system, user } = buildSocialPostPrompt(input);
|
|
80
|
+
const { text, usage } = await this.complete(system, user, 4096);
|
|
81
|
+
const parsed = parseSocialPostJson(text);
|
|
82
|
+
if (!parsed) {
|
|
83
|
+
return { worthSharing: false, content: "", usage, model: this.model };
|
|
84
|
+
}
|
|
85
|
+
return { worthSharing: parsed.worth_sharing, content: parsed.content, usage, model: this.model };
|
|
86
|
+
}
|
|
87
|
+
async answerProjectQuestion(input) {
|
|
88
|
+
const { system, user } = buildChatPrompt(input);
|
|
89
|
+
const { text, usage } = await this.complete(system, user, 4096);
|
|
90
|
+
const parsed = parseChatAnswerJson(text);
|
|
91
|
+
if (!parsed) {
|
|
92
|
+
return { answer: "Something went wrong generating an answer — try asking again.", citedStateIds: [], usage, model: this.model };
|
|
93
|
+
}
|
|
94
|
+
return { answer: parsed.answer, citedStateIds: parsed.cited_state_ids, usage, model: this.model };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=anthropic-provider.js.map
|
package/dist/llm/chat.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { retrieveContextForQuestion } from "../core/chat-retrieval.js";
|
|
3
|
+
const TITLE_MAX_LENGTH = 60;
|
|
4
|
+
/**
|
|
5
|
+
* Answers one question in an existing conversation: retrieves grounding
|
|
6
|
+
* context (recent + keyword-matched real checkpoints), asks the provider
|
|
7
|
+
* with the running conversation history, and persists both the question
|
|
8
|
+
* and the answer. The conversation's first message also sets its title —
|
|
9
|
+
* callers create conversations with a placeholder title since there's
|
|
10
|
+
* nothing to title them with until the user actually asks something.
|
|
11
|
+
*/
|
|
12
|
+
export async function askProjectQuestion(ctx, provider, projectId, conversationId, question) {
|
|
13
|
+
const priorMessages = ctx.db.listChatMessages(conversationId);
|
|
14
|
+
const conversationHistory = priorMessages.map((m) => ({ role: m.role, content: m.content }));
|
|
15
|
+
if (priorMessages.length === 0) {
|
|
16
|
+
const title = question.length > TITLE_MAX_LENGTH ? `${question.slice(0, TITLE_MAX_LENGTH - 1)}…` : question;
|
|
17
|
+
ctx.db.setChatConversationTitle(conversationId, title);
|
|
18
|
+
}
|
|
19
|
+
const groundingContext = retrieveContextForQuestion(ctx, projectId, question);
|
|
20
|
+
const userMessageId = randomUUID();
|
|
21
|
+
ctx.db.insertChatMessage({ id: userMessageId, conversationId, role: "user", content: question });
|
|
22
|
+
const result = await provider.answerProjectQuestion({ question, conversationHistory, groundingContext });
|
|
23
|
+
const assistantMessageId = randomUUID();
|
|
24
|
+
ctx.db.insertChatMessage({
|
|
25
|
+
id: assistantMessageId, conversationId, role: "assistant", content: result.answer,
|
|
26
|
+
citedStateIds: result.citedStateIds, model: result.model ?? null,
|
|
27
|
+
inputTokens: result.usage?.inputTokens ?? null, outputTokens: result.usage?.outputTokens ?? null,
|
|
28
|
+
});
|
|
29
|
+
ctx.db.touchChatConversation(conversationId);
|
|
30
|
+
const messages = ctx.db.listChatMessages(conversationId);
|
|
31
|
+
const userMessage = messages.find((m) => m.id === userMessageId);
|
|
32
|
+
const assistantMessage = messages.find((m) => m.id === assistantMessageId);
|
|
33
|
+
return { userMessage, assistantMessage };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=chat.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
// LiteLLM's community-maintained pricing dataset — a public, unauthenticated
|
|
4
|
+
// JSON file many other cost-tracking tools already lean on for exactly this
|
|
5
|
+
// problem, since neither Anthropic nor OpenAI publish pricing through an
|
|
6
|
+
// API of their own. Fetching it is a pure inbound read: no project data,
|
|
7
|
+
// prompts, or usage figures leave this machine — it's the same kind of
|
|
8
|
+
// request a browser makes loading a webpage.
|
|
9
|
+
const SOURCE_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
10
|
+
const CACHE_FILENAME = "community-pricing.json";
|
|
11
|
+
// Only these two — whydid is a BYOK, direct-to-provider tool (Anthropic or
|
|
12
|
+
// OpenAI's own APIs), never Bedrock/Vertex/Azure. The source file carries
|
|
13
|
+
// separate, differently-priced entries for those routed variants under
|
|
14
|
+
// different keys (e.g. "anthropic.claude-opus-5" for Bedrock); pulling
|
|
15
|
+
// every entry in indiscriminately would risk matching the wrong one.
|
|
16
|
+
const RELEVANT_PROVIDERS = new Set(["anthropic", "openai"]);
|
|
17
|
+
function cachePath(whydidDir) {
|
|
18
|
+
return join(whydidDir, CACHE_FILENAME);
|
|
19
|
+
}
|
|
20
|
+
/** Reads the last successfully-fetched snapshot, or null if one has never been fetched (or the file is unreadable). Never throws. */
|
|
21
|
+
export function loadCommunityPricingCache(whydidDir) {
|
|
22
|
+
const path = cachePath(whydidDir);
|
|
23
|
+
if (!existsSync(path))
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
27
|
+
if (typeof parsed?.fetchedAt === "string" && parsed?.models && typeof parsed.models === "object")
|
|
28
|
+
return parsed;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function normalize(raw) {
|
|
36
|
+
const models = {};
|
|
37
|
+
if (!raw || typeof raw !== "object")
|
|
38
|
+
return models;
|
|
39
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
40
|
+
if (!value || typeof value !== "object")
|
|
41
|
+
continue;
|
|
42
|
+
if (!RELEVANT_PROVIDERS.has(value.litellm_provider))
|
|
43
|
+
continue;
|
|
44
|
+
const inputPerToken = value.input_cost_per_token;
|
|
45
|
+
const outputPerToken = value.output_cost_per_token;
|
|
46
|
+
if (typeof inputPerToken !== "number" || typeof outputPerToken !== "number")
|
|
47
|
+
continue;
|
|
48
|
+
models[key] = { inputPerMillion: inputPerToken * 1_000_000, outputPerMillion: outputPerToken * 1_000_000 };
|
|
49
|
+
}
|
|
50
|
+
return models;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Fetches the source file, normalizes it to whydid's own per-million-token
|
|
54
|
+
* shape, and persists the result — only on explicit request (Settings has
|
|
55
|
+
* a "Refresh" button), never silently in the background, matching whydid's
|
|
56
|
+
* own local-first, nothing-happens-without-you-asking posture even though
|
|
57
|
+
* this particular call sends nothing of yours anywhere. Throws (leaving
|
|
58
|
+
* any previous cache on disk untouched) rather than overwrite a good cache
|
|
59
|
+
* with an empty one if the fetch fails or the source's schema changes
|
|
60
|
+
* enough that nothing recognizable comes back.
|
|
61
|
+
*/
|
|
62
|
+
export async function refreshCommunityPricing(whydidDir, fetchImpl = fetch) {
|
|
63
|
+
const res = await fetchImpl(SOURCE_URL);
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
throw new Error(`Fetching community pricing failed: HTTP ${res.status}`);
|
|
66
|
+
const raw = await res.json();
|
|
67
|
+
const models = normalize(raw);
|
|
68
|
+
if (Object.keys(models).length === 0) {
|
|
69
|
+
throw new Error("Community pricing source returned no recognizable Anthropic/OpenAI entries — its format may have changed.");
|
|
70
|
+
}
|
|
71
|
+
const cache = { fetchedAt: new Date().toISOString(), models };
|
|
72
|
+
writeFileSync(cachePath(whydidDir), JSON.stringify(cache, null, 2) + "\n", "utf-8");
|
|
73
|
+
return cache;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=community-pricing.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AnthropicProvider } from "./anthropic-provider.js";
|
|
2
|
+
import { OpenAIProvider } from "./openai-provider.js";
|
|
3
|
+
import { NoopProvider } from "./noop-provider.js";
|
|
4
|
+
export async function createLLMProvider(config, secretStore) {
|
|
5
|
+
if (config.provider === "anthropic") {
|
|
6
|
+
const key = await secretStore.get("anthropic");
|
|
7
|
+
if (key)
|
|
8
|
+
return new AnthropicProvider(key, config.model);
|
|
9
|
+
}
|
|
10
|
+
else if (config.provider === "openai") {
|
|
11
|
+
const key = await secretStore.get("openai");
|
|
12
|
+
if (key)
|
|
13
|
+
return new OpenAIProvider(key, config.model);
|
|
14
|
+
}
|
|
15
|
+
return new NoopProvider();
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=factory.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a non-OK HTTP response from an LLM provider into a clear,
|
|
3
|
+
* actionable message instead of dumping whatever the server sent back
|
|
4
|
+
* (which, for infrastructure-level failures like a Cloudflare 5xx, is a
|
|
5
|
+
* full HTML error page — confusing and unhelpful shown raw to the user).
|
|
6
|
+
* Recognizable categories get a plain-language explanation; anything else
|
|
7
|
+
* still shows the real body, just not blindly for HTML.
|
|
8
|
+
*/
|
|
9
|
+
export function describeHttpError(providerName, apiKeyEnvVar, status, body) {
|
|
10
|
+
const looksLikeHtml = /^\s*<(!doctype|html)/i.test(body);
|
|
11
|
+
if (status >= 500) {
|
|
12
|
+
return `${providerName}'s API is temporarily unavailable (HTTP ${status}) — this is on their end, not a whydid problem. Try again in a moment.`;
|
|
13
|
+
}
|
|
14
|
+
if (status === 429) {
|
|
15
|
+
return `${providerName} API rate limit reached — wait a bit and try again, or check your usage limits in your ${providerName} account.`;
|
|
16
|
+
}
|
|
17
|
+
if (status === 401 || status === 403) {
|
|
18
|
+
return `${providerName} API rejected the request (HTTP ${status}) — check that ${apiKeyEnvVar} is set correctly and hasn't expired or been revoked.`;
|
|
19
|
+
}
|
|
20
|
+
if (looksLikeHtml) {
|
|
21
|
+
return `${providerName} API error ${status} (received an HTML error page instead of a normal API response — likely a temporary issue on their end, not a whydid problem). Try again in a moment.`;
|
|
22
|
+
}
|
|
23
|
+
return `${providerName} API error ${status}: ${body.slice(0, 500)}`;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=http-error.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class NoopProvider {
|
|
2
|
+
id = "noop";
|
|
3
|
+
async models() {
|
|
4
|
+
return [];
|
|
5
|
+
}
|
|
6
|
+
async generateAnnotation(_input) {
|
|
7
|
+
return { summary: "", reason: "", decisions: [], riskLevel: "low", status: "unavailable" };
|
|
8
|
+
}
|
|
9
|
+
async answerQuestion(_question, _context) {
|
|
10
|
+
return "AI explanation unavailable: no LLM provider is configured yet.";
|
|
11
|
+
}
|
|
12
|
+
async generateSocialPost(_input) {
|
|
13
|
+
return { worthSharing: false, content: "" };
|
|
14
|
+
}
|
|
15
|
+
async answerProjectQuestion(_input) {
|
|
16
|
+
return { answer: "AI explanation unavailable: no LLM provider is configured yet.", citedStateIds: [] };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=noop-provider.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { buildAnnotationPrompt, buildQuestionPrompt, buildSocialPostPrompt, buildChatPrompt } from "./prompt.js";
|
|
2
|
+
import { parseAnnotationJson, parseSocialPostJson, parseChatAnswerJson } from "./schema.js";
|
|
3
|
+
import { describeHttpError } from "./http-error.js";
|
|
4
|
+
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|
5
|
+
const DEFAULT_MODEL = "gpt-4o";
|
|
6
|
+
const KNOWN_MODELS = ["gpt-4o", "gpt-4o-mini", "gpt-4.1"];
|
|
7
|
+
export class OpenAIProvider {
|
|
8
|
+
apiKey;
|
|
9
|
+
id = "openai";
|
|
10
|
+
model;
|
|
11
|
+
constructor(apiKey, model) {
|
|
12
|
+
this.apiKey = apiKey;
|
|
13
|
+
this.model = model && model.length > 0 ? model : DEFAULT_MODEL;
|
|
14
|
+
}
|
|
15
|
+
async models() {
|
|
16
|
+
return KNOWN_MODELS;
|
|
17
|
+
}
|
|
18
|
+
async complete(system, user, jsonMode) {
|
|
19
|
+
const res = await fetch(OPENAI_API_URL, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
model: this.model,
|
|
27
|
+
messages: [
|
|
28
|
+
{ role: "system", content: system },
|
|
29
|
+
{ role: "user", content: user },
|
|
30
|
+
],
|
|
31
|
+
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.text().catch(() => "");
|
|
36
|
+
throw new Error(describeHttpError("OpenAI", "OPENAI_API_KEY", res.status, body));
|
|
37
|
+
}
|
|
38
|
+
const data = (await res.json());
|
|
39
|
+
const text = data.choices[0]?.message.content;
|
|
40
|
+
if (!text)
|
|
41
|
+
throw new Error("OpenAI API returned no message content");
|
|
42
|
+
return { text, usage: { inputTokens: data.usage.prompt_tokens, outputTokens: data.usage.completion_tokens } };
|
|
43
|
+
}
|
|
44
|
+
async generateAnnotation(input) {
|
|
45
|
+
const { system, user } = buildAnnotationPrompt(input);
|
|
46
|
+
const { text, usage } = await this.complete(system, user, true);
|
|
47
|
+
const parsed = parseAnnotationJson(text);
|
|
48
|
+
if (!parsed) {
|
|
49
|
+
return { summary: "", reason: "", decisions: [], riskLevel: "low", status: "failed", usage, model: this.model };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
summary: parsed.summary,
|
|
53
|
+
reason: parsed.reason,
|
|
54
|
+
decisions: parsed.decisions,
|
|
55
|
+
riskLevel: parsed.risk_level,
|
|
56
|
+
status: "generated",
|
|
57
|
+
usage,
|
|
58
|
+
model: this.model,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async answerQuestion(question, context) {
|
|
62
|
+
const { system, user } = buildQuestionPrompt(question, context);
|
|
63
|
+
const { text } = await this.complete(system, user, false);
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
async generateSocialPost(input) {
|
|
67
|
+
const { system, user } = buildSocialPostPrompt(input);
|
|
68
|
+
const { text, usage } = await this.complete(system, user, true);
|
|
69
|
+
const parsed = parseSocialPostJson(text);
|
|
70
|
+
if (!parsed) {
|
|
71
|
+
return { worthSharing: false, content: "", usage, model: this.model };
|
|
72
|
+
}
|
|
73
|
+
return { worthSharing: parsed.worth_sharing, content: parsed.content, usage, model: this.model };
|
|
74
|
+
}
|
|
75
|
+
async answerProjectQuestion(input) {
|
|
76
|
+
const { system, user } = buildChatPrompt(input);
|
|
77
|
+
const { text, usage } = await this.complete(system, user, true);
|
|
78
|
+
const parsed = parseChatAnswerJson(text);
|
|
79
|
+
if (!parsed) {
|
|
80
|
+
return { answer: "Something went wrong generating an answer — try asking again.", citedStateIds: [], usage, model: this.model };
|
|
81
|
+
}
|
|
82
|
+
return { answer: parsed.answer, citedStateIds: parsed.cited_state_ids, usage, model: this.model };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=openai-provider.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Published list pricing, verified via live web search on 2026-09-23 (not
|
|
2
|
+
// from training data, which can be stale) — see the commit that introduced
|
|
3
|
+
// this file for sources. Prices genuinely change; this is deliberately a
|
|
4
|
+
// small, easy-to-audit table rather than baked into a bigger structure, so
|
|
5
|
+
// it's easy to spot-check and update. Never guess a price for a model not
|
|
6
|
+
// listed here — estimateCostUsd returns null instead, and callers must
|
|
7
|
+
// treat that as "unknown," not zero.
|
|
8
|
+
export const MODEL_PRICING = {
|
|
9
|
+
"claude-opus-5": { inputPerMillion: 5, outputPerMillion: 25 },
|
|
10
|
+
"claude-sonnet-5": { inputPerMillion: 2, outputPerMillion: 10 },
|
|
11
|
+
"claude-haiku-4-5-20251001": { inputPerMillion: 1, outputPerMillion: 5 },
|
|
12
|
+
"gpt-4o": { inputPerMillion: 2.5, outputPerMillion: 10 },
|
|
13
|
+
"gpt-4o-mini": { inputPerMillion: 0.15, outputPerMillion: 0.6 },
|
|
14
|
+
"gpt-4.1": { inputPerMillion: 2, outputPerMillion: 8 },
|
|
15
|
+
};
|
|
16
|
+
export const PRICING_DISCLAIMER = "Estimated from published list pricing verified September 2026. Your actual bill may differ — volume discounts, prompt caching, batch pricing, or price changes since this was checked aren't reflected here. Check your provider's dashboard for your real invoice.";
|
|
17
|
+
/** Resolves a model's rate and its provenance — override, then whydid's own verified table, then the community-sourced cache — or null if nothing has it. */
|
|
18
|
+
export function resolveModelPricing(model, lookup = {}) {
|
|
19
|
+
const override = lookup.overrides?.[model];
|
|
20
|
+
if (override)
|
|
21
|
+
return { ...override, source: "override" };
|
|
22
|
+
const verified = MODEL_PRICING[model];
|
|
23
|
+
if (verified)
|
|
24
|
+
return { ...verified, source: "verified" };
|
|
25
|
+
const community = lookup.community?.[model];
|
|
26
|
+
if (community)
|
|
27
|
+
return { ...community, source: "community" };
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Null means "no pricing on file for this model" — never fabricate a number for an unrecognized or future model. */
|
|
31
|
+
export function estimateCostUsd(model, inputTokens, outputTokens, lookup = {}) {
|
|
32
|
+
const pricing = resolveModelPricing(model, lookup);
|
|
33
|
+
if (!pricing)
|
|
34
|
+
return null;
|
|
35
|
+
return (inputTokens / 1_000_000) * pricing.inputPerMillion + (outputTokens / 1_000_000) * pricing.outputPerMillion;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Compares month-to-date estimated spend against a founder-set monthly
|
|
39
|
+
* limit. `spentUsd` is null when every model used this month is unpriced
|
|
40
|
+
* (estimateCostUsd returned null for all of it) — that's reported as
|
|
41
|
+
* "unknown" rather than silently treated as $0 spent, since a real budget
|
|
42
|
+
* could quietly be blown past on an unpriced model otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export function computeBudgetStatus(monthlyLimitUsd, alertThresholdPercent, spentUsd) {
|
|
45
|
+
if (monthlyLimitUsd == null) {
|
|
46
|
+
return { status: "disabled", limitUsd: null, spentUsd, percentUsed: null };
|
|
47
|
+
}
|
|
48
|
+
if (spentUsd == null) {
|
|
49
|
+
return { status: "disabled", limitUsd: monthlyLimitUsd, spentUsd: null, percentUsed: null };
|
|
50
|
+
}
|
|
51
|
+
const percentUsed = monthlyLimitUsd > 0 ? (spentUsd / monthlyLimitUsd) * 100 : spentUsd > 0 ? Infinity : 0;
|
|
52
|
+
const status = percentUsed >= 100 ? "exceeded" : percentUsed >= alertThresholdPercent ? "warning" : "ok";
|
|
53
|
+
return { status, limitUsd: monthlyLimitUsd, spentUsd, percentUsed };
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=pricing.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const ANNOTATION_SYSTEM_PROMPT = `You are analyzing a code change for a developer tool called whydid, which \
|
|
2
|
+
tracks AI-assisted coding history. Given a diff and context, explain what changed and why, as a developer \
|
|
3
|
+
would explain it in a commit message or PR description. When the user's actual instruction is provided, treat \
|
|
4
|
+
it as ground truth for "why" — reserve inference for "what" and "decisions". Never invent intent you cannot \
|
|
5
|
+
support from the instruction or the diff — if the reason is genuinely unclear, say so plainly instead of \
|
|
6
|
+
guessing.
|
|
7
|
+
|
|
8
|
+
Respond with ONLY a JSON object, no prose before or after, matching exactly this shape:
|
|
9
|
+
{
|
|
10
|
+
"summary": "one-line summary of what changed",
|
|
11
|
+
"reason": "why this change was likely made, grounded in the user's instruction when given, else the diff",
|
|
12
|
+
"decisions": ["notable implementation decisions visible in the diff"],
|
|
13
|
+
"risk_level": "low" | "medium" | "high"
|
|
14
|
+
}`;
|
|
15
|
+
export function buildAnnotationPrompt(input) {
|
|
16
|
+
const instructionLine = input.userInstruction ? `The user's actual instruction to the AI agent was:\n"${input.userInstruction}"\n\n` : "";
|
|
17
|
+
const filesLine = input.filesOfInterest.length > 0 ? `Files of interest: ${input.filesOfInterest.join(", ")}\n\n` : "";
|
|
18
|
+
const user = `${instructionLine}${filesLine}Context:\n${input.summaryContext || "(no prior context)"}\n\nDiff:\n${input.diffText || "(no diff text)"}`;
|
|
19
|
+
return { system: ANNOTATION_SYSTEM_PROMPT, user };
|
|
20
|
+
}
|
|
21
|
+
const QUESTION_SYSTEM_PROMPT = `You are answering a developer's question about why a piece of their code \
|
|
22
|
+
changed, using whydid's recorded history as context. Be concise and concrete. If the recorded context does \
|
|
23
|
+
not actually answer the question, say so rather than speculating.`;
|
|
24
|
+
export function buildQuestionPrompt(question, context) {
|
|
25
|
+
return { system: QUESTION_SYSTEM_PROMPT, user: `Context:\n${context}\n\nQuestion:\n${question}` };
|
|
26
|
+
}
|
|
27
|
+
const SOCIAL_POST_BASE_PROMPT = `You are drafting a "build in public" social media post for a solo/indie \
|
|
28
|
+
founder, based on a real coding session recorded by whydid — a local tool that tracks AI-assisted coding \
|
|
29
|
+
history. You're given the real prompts they gave their AI coding agent and whydid's own summary of what \
|
|
30
|
+
actually happened in each resulting change.
|
|
31
|
+
|
|
32
|
+
First, judge honestly whether this session is actually worth a public post. A session that shipped a real, \
|
|
33
|
+
describable piece of work — a feature, a fix users would notice, a meaningful decision — is worth sharing. \
|
|
34
|
+
Trivial, in-progress, or purely mechanical work (a typo fix, a lint pass, an aborted experiment with nothing \
|
|
35
|
+
to show) is not — set worth_sharing to false and leave content empty rather than manufacturing hype from \
|
|
36
|
+
nothing. Never fabricate outcomes, numbers, or user impact that aren't actually supported by the session data.
|
|
37
|
+
|
|
38
|
+
Write in first person, like a builder sharing genuine progress — not marketing copy, no corporate voice, no \
|
|
39
|
+
excessive emoji, no engagement-bait phrasing ("You won't believe..."). Specific beats vague: name what was \
|
|
40
|
+
actually built, not just "made progress."
|
|
41
|
+
|
|
42
|
+
Respond with ONLY a JSON object, no prose before or after, matching exactly this shape:
|
|
43
|
+
{
|
|
44
|
+
"worth_sharing": true | false,
|
|
45
|
+
"content": "the drafted post text, or \\"\\" when worth_sharing is false"
|
|
46
|
+
}`;
|
|
47
|
+
const PLATFORM_INSTRUCTIONS = {
|
|
48
|
+
x: `Platform: X (Twitter). Stay under 260 characters total (leaves room to paste without trimming). Punchy, \
|
|
49
|
+
one clear idea, no hashtag spam (zero or one relevant hashtag at most). A short thread is fine only if the \
|
|
50
|
+
work genuinely has two distinct beats — otherwise one tight post.`,
|
|
51
|
+
linkedin: `Platform: LinkedIn. 3-6 short paragraphs, more narrative and reflective than X — the "why", not \
|
|
52
|
+
just the "what". Professional but personable, like a founder update to people who'd genuinely want to follow \
|
|
53
|
+
along, not a press release.`,
|
|
54
|
+
};
|
|
55
|
+
export function buildSocialPostPrompt(input) {
|
|
56
|
+
const system = `${SOCIAL_POST_BASE_PROMPT}\n\n${PLATFORM_INSTRUCTIONS[input.platform]}`;
|
|
57
|
+
const promptsBlock = input.sessionPrompts.length > 0
|
|
58
|
+
? input.sessionPrompts.map((p) => `- ${p}`).join("\n")
|
|
59
|
+
: "(no recorded prompts for this session)";
|
|
60
|
+
const summariesBlock = input.checkpointSummaries.length > 0
|
|
61
|
+
? input.checkpointSummaries
|
|
62
|
+
.map((c) => {
|
|
63
|
+
const decisions = c.decisions.length > 0 ? ` Decisions: ${c.decisions.join("; ")}.` : "";
|
|
64
|
+
const risk = c.riskLevel ? ` (${c.riskLevel} risk)` : "";
|
|
65
|
+
return `- ${c.summary}${risk} — ${c.reason}${decisions}`;
|
|
66
|
+
})
|
|
67
|
+
.join("\n")
|
|
68
|
+
: "(no annotated checkpoints for this session)";
|
|
69
|
+
const user = `What the founder actually asked for during this session:\n${promptsBlock}\n\nWhat whydid recorded actually happened:\n${summariesBlock}`;
|
|
70
|
+
return { system, user };
|
|
71
|
+
}
|
|
72
|
+
const CHAT_SYSTEM_PROMPT = `You are whydid's project assistant. You help a founder understand their own \
|
|
73
|
+
project's real history — what changed, why, what was decided, and what they actually did — using ONLY the \
|
|
74
|
+
real checkpoint history provided below as your source of truth. Never invent facts, dates, or outcomes not \
|
|
75
|
+
grounded in that context. If the provided context doesn't actually answer the question, say so plainly \
|
|
76
|
+
instead of guessing or padding the answer. Be concise and concrete — this is a founder who wants a real \
|
|
77
|
+
answer, not an essay. Each piece of context is labeled with a state id in brackets like [state abc123] —
|
|
78
|
+
whenever your answer draws on one, include its id in cited_state_ids so it can be linked back to.
|
|
79
|
+
|
|
80
|
+
Respond with ONLY a JSON object, no prose before or after, matching exactly this shape:
|
|
81
|
+
{
|
|
82
|
+
"answer": "your answer, in plain text (light markdown is fine)",
|
|
83
|
+
"cited_state_ids": ["state ids from the provided context that your answer actually draws from"]
|
|
84
|
+
}`;
|
|
85
|
+
export function buildChatPrompt(input) {
|
|
86
|
+
const contextBlock = input.groundingContext.length > 0
|
|
87
|
+
? input.groundingContext
|
|
88
|
+
.map((c) => {
|
|
89
|
+
const risk = c.riskLevel ? ` (${c.riskLevel} risk)` : "";
|
|
90
|
+
const decisions = c.decisions.length > 0 ? `\n Decisions: ${c.decisions.join("; ")}` : "";
|
|
91
|
+
const prompt = c.prompt ? `\n Prompt: "${c.prompt}"` : "";
|
|
92
|
+
const files = c.filesTouched.length > 0 ? `\n Files: ${c.filesTouched.join(", ")}` : "";
|
|
93
|
+
return `[state ${c.stateId}] ${new Date(c.createdAt).toLocaleString()} — ${c.summary}${risk}\n Reason: ${c.reason}${decisions}${prompt}${files}`;
|
|
94
|
+
})
|
|
95
|
+
.join("\n\n")
|
|
96
|
+
: "(no relevant recorded history found for this question)";
|
|
97
|
+
const historyBlock = input.conversationHistory.length > 0
|
|
98
|
+
? input.conversationHistory.map((m) => `${m.role}: ${m.content}`).join("\n")
|
|
99
|
+
: "(this is the first message in the conversation)";
|
|
100
|
+
const user = `Conversation so far:\n${historyBlock}\n\nRelevant project history:\n${contextBlock}\n\nQuestion:\n${input.question}`;
|
|
101
|
+
return { system: CHAT_SYSTEM_PROMPT, user };
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=prompt.js.map
|