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,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds an Express app to 127.0.0.1, preferring a specific port if given
|
|
3
|
+
* (so a restarted daemon can often reclaim its previous URL and let an
|
|
4
|
+
* already-open dashboard tab reconnect on a plain reload) and falling back
|
|
5
|
+
* to a random free port — the prior behavior — when that port is taken by
|
|
6
|
+
* something else, or none was requested.
|
|
7
|
+
*/
|
|
8
|
+
export function listenWithPortReuse(app, preferredPort) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const tryListen = (port, allowFallback) => {
|
|
11
|
+
const server = app.listen(port, "127.0.0.1");
|
|
12
|
+
server.once("listening", () => resolve(server));
|
|
13
|
+
server.once("error", (err) => {
|
|
14
|
+
if (allowFallback && err.code === "EADDRINUSE") {
|
|
15
|
+
tryListen(0, false);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
reject(err);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
const wantsSpecificPort = preferredPort != null && preferredPort > 0;
|
|
23
|
+
tryListen(wantsSpecificPort ? preferredPort : 0, wantsSpecificPort);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=listen.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
function path(whydidDir) {
|
|
4
|
+
return join(whydidDir, "daemon.pid.json");
|
|
5
|
+
}
|
|
6
|
+
export function writePidFile(whydidDir, data) {
|
|
7
|
+
writeFileSync(path(whydidDir), JSON.stringify(data), "utf-8");
|
|
8
|
+
}
|
|
9
|
+
export function readPidFile(whydidDir) {
|
|
10
|
+
const p = path(whydidDir);
|
|
11
|
+
if (!existsSync(p))
|
|
12
|
+
return null;
|
|
13
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
14
|
+
}
|
|
15
|
+
export function removePidFile(whydidDir) {
|
|
16
|
+
const p = path(whydidDir);
|
|
17
|
+
if (existsSync(p))
|
|
18
|
+
unlinkSync(p);
|
|
19
|
+
}
|
|
20
|
+
// A daemon that dies from an uncaught exception (or SIGKILL) never runs its
|
|
21
|
+
// SIGTERM shutdown handler, so its pidfile is never cleaned up — without
|
|
22
|
+
// this check, `daemon start` trusts that stale file forever and reports a
|
|
23
|
+
// dead process as "already running", refusing to actually start anything.
|
|
24
|
+
// Signal 0 sends nothing; the OS just validates the pid exists.
|
|
25
|
+
export function isProcessAlive(pid) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
// EPERM means the process exists but we lack permission to signal it —
|
|
32
|
+
// still alive. Any other error (ESRCH: no such process) means it's dead.
|
|
33
|
+
return err.code === "EPERM";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=pidfile.js.map
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { restoreState, restoreFiles, forkState } from "../core/engine.js";
|
|
7
|
+
import { computeFileDiff } from "../core/diff.js";
|
|
8
|
+
import { computeRecap } from "../core/recap.js";
|
|
9
|
+
import { getFileHistory } from "../core/file-history.js";
|
|
10
|
+
import { loadConfig, saveConfig } from "../shared/config.js";
|
|
11
|
+
import { createLLMProvider } from "../llm/factory.js";
|
|
12
|
+
import { FileSecretStore, getSecretStatuses } from "../licensing/secret-store.js";
|
|
13
|
+
import { generateAgentPrompt } from "../llm/agent-prompt.js";
|
|
14
|
+
import { buildExportData, exportAsJson, exportAsMarkdown, exportAsHtml } from "../core/export.js";
|
|
15
|
+
import { resolveModelPricing, PRICING_DISCLAIMER } from "../llm/pricing.js";
|
|
16
|
+
import { loadCommunityPricingCache, refreshCommunityPricing } from "../llm/community-pricing.js";
|
|
17
|
+
import { computeCurrentAiBudget } from "../core/ai-budget.js";
|
|
18
|
+
import { generateAndSaveSocialPost } from "../llm/social-post.js";
|
|
19
|
+
import { askProjectQuestion } from "../llm/chat.js";
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
// dist/daemon/server.js -> package root is two levels up (dist/daemon -> dist -> root)
|
|
22
|
+
const PACKAGE_ROOT = join(__dirname, "..", "..");
|
|
23
|
+
export function createDaemonApp(ctx, projectId, token, watcher) {
|
|
24
|
+
const app = express();
|
|
25
|
+
app.use(express.json());
|
|
26
|
+
// A PUT /api/config patch only ever carries the one model being
|
|
27
|
+
// added, changed, or removed — never the whole map — so this merges
|
|
28
|
+
// key-by-key rather than replacing pricingOverrides outright. Since JSON
|
|
29
|
+
// has no "undefined", removal is expressed as an explicit null value for
|
|
30
|
+
// that key, not by omitting it.
|
|
31
|
+
function mergePricingOverrides(current, patch) {
|
|
32
|
+
if (!patch)
|
|
33
|
+
return current;
|
|
34
|
+
const next = { ...current };
|
|
35
|
+
for (const [model, rate] of Object.entries(patch)) {
|
|
36
|
+
if (rate === null)
|
|
37
|
+
delete next[model];
|
|
38
|
+
else
|
|
39
|
+
next[model] = rate;
|
|
40
|
+
}
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
// A state's turnId points at whatever turn was active when it was
|
|
44
|
+
// checkpointed — cheap indexed PK lookup, not a join, since most states
|
|
45
|
+
// have no turn at all (manual/CLI checkpoints) or an ordinary one.
|
|
46
|
+
function resumedFromCompaction(turnId) {
|
|
47
|
+
return turnId != null && ctx.db.getTurn(turnId)?.kind === "compact_resume";
|
|
48
|
+
}
|
|
49
|
+
app.get("/api/health", (_req, res) => res.json({ ok: true }));
|
|
50
|
+
app.use("/api", (req, res, next) => {
|
|
51
|
+
if (req.query.token !== token) {
|
|
52
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
app.get("/api/states", (req, res) => {
|
|
58
|
+
const limit = req.query.limit != null ? Math.max(1, Math.min(200, Number(req.query.limit))) : undefined;
|
|
59
|
+
const offset = req.query.offset != null ? Math.max(0, Number(req.query.offset)) : 0;
|
|
60
|
+
const search = typeof req.query.search === "string" ? req.query.search : undefined;
|
|
61
|
+
const risk = typeof req.query.risk === "string" && req.query.risk !== "all" ? req.query.risk : undefined;
|
|
62
|
+
const filterOpts = { search, risk };
|
|
63
|
+
const total = ctx.db.countStates(projectId, filterOpts);
|
|
64
|
+
const states = ctx.db.listStates(projectId, { ...filterOpts, limit, offset }).map((s) => {
|
|
65
|
+
const changes = ctx.db.getFileChanges(s.id);
|
|
66
|
+
const annotation = ctx.db.getAnnotation(s.id) ?? null;
|
|
67
|
+
return {
|
|
68
|
+
...s,
|
|
69
|
+
fileCount: changes.length,
|
|
70
|
+
linesAdded: changes.reduce((sum, c) => sum + c.linesAdded, 0),
|
|
71
|
+
linesRemoved: changes.reduce((sum, c) => sum + c.linesRemoved, 0),
|
|
72
|
+
annotation,
|
|
73
|
+
resumedFromCompaction: resumedFromCompaction(s.turnId),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
res.json({ states, total, offset, limit: limit ?? total });
|
|
77
|
+
});
|
|
78
|
+
app.get("/api/milestones", (_req, res) => {
|
|
79
|
+
res.json({ milestones: ctx.db.listMilestones(projectId) });
|
|
80
|
+
});
|
|
81
|
+
// created_at is always stored as a UTC ISO string (new Date().toISOString()),
|
|
82
|
+
// and getDailyCheckpointCounts buckets by substr(created_at, 1, 10) — the
|
|
83
|
+
// UTC calendar date. Day-boundary math here has to stay in UTC too, or the
|
|
84
|
+
// zero-filled day list drifts by a day against what the DB actually grouped
|
|
85
|
+
// (as it would using local-time methods like setHours/setDate in a non-UTC
|
|
86
|
+
// timezone).
|
|
87
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
88
|
+
function utcMidnightDaysAgo(days) {
|
|
89
|
+
const now = new Date();
|
|
90
|
+
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1));
|
|
91
|
+
}
|
|
92
|
+
app.get("/api/recap", (req, res) => {
|
|
93
|
+
const days = req.query.days != null ? Math.max(1, Math.min(90, Number(req.query.days))) : 1;
|
|
94
|
+
res.json(computeRecap(ctx.db, projectId, new Date(utcMidnightDaysAgo(days)).toISOString()));
|
|
95
|
+
});
|
|
96
|
+
// Every point where a coding agent's own conversation context ran out and
|
|
97
|
+
// got auto-compacted mid-session — the moment whydid's real, on-disk
|
|
98
|
+
// checkpoint history matters most, since that's exactly when an agent's
|
|
99
|
+
// own memory of the session is a lossy summary rather than the real
|
|
100
|
+
// thing. For each one, a real recap (computeRecap) of everything that
|
|
101
|
+
// actually happened since, so a resumed session's claims can be checked
|
|
102
|
+
// against real file changes instead of taken on faith.
|
|
103
|
+
app.get("/api/continuity", (_req, res) => {
|
|
104
|
+
const resumes = ctx.db.listCompactResumeTurns(projectId).map((turn) => ({
|
|
105
|
+
turnId: turn.id,
|
|
106
|
+
sessionId: turn.sessionId,
|
|
107
|
+
startedAt: turn.startedAt,
|
|
108
|
+
summary: turn.prompt,
|
|
109
|
+
recap: computeRecap(ctx.db, projectId, turn.startedAt),
|
|
110
|
+
}));
|
|
111
|
+
res.json({ resumes });
|
|
112
|
+
});
|
|
113
|
+
app.get("/api/usage", (req, res) => {
|
|
114
|
+
const days = req.query.days != null ? Math.max(1, Math.min(90, Number(req.query.days))) : 14;
|
|
115
|
+
const sinceMs = utcMidnightDaysAgo(days);
|
|
116
|
+
const byDay = new Map(ctx.db.getDailyCheckpointCounts(projectId, new Date(sinceMs).toISOString()).map((d) => [d.date, d.count]));
|
|
117
|
+
const daily = [];
|
|
118
|
+
for (let i = 0; i < days; i++) {
|
|
119
|
+
const key = new Date(sinceMs + i * ONE_DAY_MS).toISOString().slice(0, 10);
|
|
120
|
+
daily.push({ date: key, count: byDay.get(key) ?? 0 });
|
|
121
|
+
}
|
|
122
|
+
const totals = ctx.db.getProjectTotals(projectId);
|
|
123
|
+
const project = ctx.db.getProject(projectId);
|
|
124
|
+
res.json({ daily, ...totals, projectCreatedAt: project?.createdAt ?? null });
|
|
125
|
+
});
|
|
126
|
+
// Shared by /api/ai-cost and /api/ai-budget (via computeCurrentAiBudget)
|
|
127
|
+
// so a model's price — and which source it came from — can never
|
|
128
|
+
// disagree between the two.
|
|
129
|
+
function buildPricingLookup() {
|
|
130
|
+
return { overrides: loadConfig(ctx.whydidDir).pricingOverrides, community: loadCommunityPricingCache(ctx.whydidDir)?.models };
|
|
131
|
+
}
|
|
132
|
+
app.get("/api/ai-cost", (req, res) => {
|
|
133
|
+
const days = req.query.days != null ? Math.max(1, Math.min(90, Number(req.query.days))) : 14;
|
|
134
|
+
const sinceMs = utcMidnightDaysAgo(days);
|
|
135
|
+
const byDay = new Map(ctx.db.getDailyTokenUsage(projectId, new Date(sinceMs).toISOString()).map((d) => [d.date, d]));
|
|
136
|
+
const daily = [];
|
|
137
|
+
for (let i = 0; i < days; i++) {
|
|
138
|
+
const key = new Date(sinceMs + i * ONE_DAY_MS).toISOString().slice(0, 10);
|
|
139
|
+
const found = byDay.get(key);
|
|
140
|
+
daily.push({ date: key, inputTokens: found?.inputTokens ?? 0, outputTokens: found?.outputTokens ?? 0 });
|
|
141
|
+
}
|
|
142
|
+
// Cost is only ever computed per-model (rates differ per model) then
|
|
143
|
+
// summed — never blended across models at the day level, which would
|
|
144
|
+
// silently misstate cost on any day usage was split across models.
|
|
145
|
+
const lookup = buildPricingLookup();
|
|
146
|
+
const byModel = ctx.db.getTokenUsageByModel(projectId).map((m) => {
|
|
147
|
+
const resolved = resolveModelPricing(m.model, lookup);
|
|
148
|
+
return {
|
|
149
|
+
...m,
|
|
150
|
+
estimatedCostUsd: resolved ? (m.inputTokens / 1_000_000) * resolved.inputPerMillion + (m.outputTokens / 1_000_000) * resolved.outputPerMillion : null,
|
|
151
|
+
pricingSource: resolved?.source ?? null,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
const totalInputTokens = byModel.reduce((sum, m) => sum + m.inputTokens, 0);
|
|
155
|
+
const totalOutputTokens = byModel.reduce((sum, m) => sum + m.outputTokens, 0);
|
|
156
|
+
const pricedModels = byModel.filter((m) => m.estimatedCostUsd !== null);
|
|
157
|
+
const totalEstimatedCostUsd = pricedModels.length > 0 ? pricedModels.reduce((sum, m) => sum + (m.estimatedCostUsd ?? 0), 0) : null;
|
|
158
|
+
const hasUnpricedUsage = byModel.some((m) => m.estimatedCostUsd === null);
|
|
159
|
+
res.json({ daily, byModel, totalInputTokens, totalOutputTokens, totalEstimatedCostUsd, hasUnpricedUsage, pricingDisclaimer: PRICING_DISCLAIMER });
|
|
160
|
+
});
|
|
161
|
+
app.get("/api/pricing/community-status", (_req, res) => {
|
|
162
|
+
const cache = loadCommunityPricingCache(ctx.whydidDir);
|
|
163
|
+
res.json({ fetchedAt: cache?.fetchedAt ?? null, modelCount: cache ? Object.keys(cache.models).length : 0 });
|
|
164
|
+
});
|
|
165
|
+
app.post("/api/pricing/community-refresh", async (_req, res) => {
|
|
166
|
+
try {
|
|
167
|
+
const cache = await refreshCommunityPricing(ctx.whydidDir);
|
|
168
|
+
res.json({ fetchedAt: cache.fetchedAt, modelCount: Object.keys(cache.models).length });
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
res.status(502).json({ error: e instanceof Error ? e.message : "Failed to refresh community pricing" });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
app.get("/api/ai-budget", (_req, res) => {
|
|
175
|
+
const config = loadConfig(ctx.whydidDir);
|
|
176
|
+
const budget = computeCurrentAiBudget(ctx, projectId, config);
|
|
177
|
+
res.json({ ...budget, pricingDisclaimer: PRICING_DISCLAIMER });
|
|
178
|
+
});
|
|
179
|
+
app.get("/api/context", (_req, res) => {
|
|
180
|
+
res.json(ctx.db.listContextFiles(projectId));
|
|
181
|
+
});
|
|
182
|
+
app.get("/api/social-posts", (_req, res) => {
|
|
183
|
+
res.json(ctx.db.listSocialPosts(projectId));
|
|
184
|
+
});
|
|
185
|
+
app.post("/api/social-posts/generate", async (req, res) => {
|
|
186
|
+
const sessionId = req.body?.sessionId;
|
|
187
|
+
const platform = req.body?.platform;
|
|
188
|
+
if (!sessionId || !ctx.db.getSession(sessionId)) {
|
|
189
|
+
res.status(404).json({ error: "Unknown session" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (platform !== "x" && platform !== "linkedin") {
|
|
193
|
+
res.status(400).json({ error: 'platform must be "x" or "linkedin"' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const config = loadConfig(ctx.whydidDir);
|
|
197
|
+
const provider = await createLLMProvider(config.ai, new FileSecretStore(ctx.whydidDir));
|
|
198
|
+
if (provider.id === "noop") {
|
|
199
|
+
res.status(422).json({ error: "No LLM provider configured — set ai.provider and the matching *_API_KEY." });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const result = await generateAndSaveSocialPost(ctx, provider, projectId, sessionId, platform, "manual", config.buildInPublic.retentionDays);
|
|
204
|
+
if (!result.saved) {
|
|
205
|
+
res.status(200).json({ saved: false, post: null, message: "This session doesn't look substantial enough for a post — try a different session, or continue working first." });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
res.json({ saved: true, post: result.post });
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
res.status(502).json({ error: err instanceof Error ? err.message : "Post generation failed" });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
app.post("/api/social-posts/:id/pin", (req, res) => {
|
|
215
|
+
const post = ctx.db.getSocialPost(req.params.id);
|
|
216
|
+
if (!post) {
|
|
217
|
+
res.status(404).json({ error: "Not found" });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const pinned = Boolean(req.body?.pinned);
|
|
221
|
+
ctx.db.setSocialPostPinned(req.params.id, pinned);
|
|
222
|
+
res.json({ ...post, pinned });
|
|
223
|
+
});
|
|
224
|
+
app.delete("/api/social-posts/:id", (req, res) => {
|
|
225
|
+
const post = ctx.db.getSocialPost(req.params.id);
|
|
226
|
+
if (!post) {
|
|
227
|
+
res.status(404).json({ error: "Not found" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
ctx.db.deleteSocialPost(req.params.id);
|
|
231
|
+
res.json({ ok: true });
|
|
232
|
+
});
|
|
233
|
+
app.get("/api/chat/conversations", (_req, res) => {
|
|
234
|
+
res.json(ctx.db.listChatConversations(projectId));
|
|
235
|
+
});
|
|
236
|
+
app.post("/api/chat/conversations", (_req, res) => {
|
|
237
|
+
const id = randomUUID();
|
|
238
|
+
ctx.db.insertChatConversation({ id, projectId, title: "New conversation" });
|
|
239
|
+
res.json(ctx.db.getChatConversation(id));
|
|
240
|
+
});
|
|
241
|
+
app.get("/api/chat/conversations/:id", (req, res) => {
|
|
242
|
+
const conversation = ctx.db.getChatConversation(req.params.id);
|
|
243
|
+
if (!conversation || conversation.projectId !== projectId) {
|
|
244
|
+
res.status(404).json({ error: "Not found" });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
res.json({ ...conversation, messages: ctx.db.listChatMessages(conversation.id) });
|
|
248
|
+
});
|
|
249
|
+
app.post("/api/chat/conversations/:id/messages", async (req, res) => {
|
|
250
|
+
const conversation = ctx.db.getChatConversation(req.params.id);
|
|
251
|
+
if (!conversation || conversation.projectId !== projectId) {
|
|
252
|
+
res.status(404).json({ error: "Not found" });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const question = req.body?.question;
|
|
256
|
+
if (!question || !question.trim()) {
|
|
257
|
+
res.status(400).json({ error: "question is required" });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const config = loadConfig(ctx.whydidDir);
|
|
261
|
+
const provider = await createLLMProvider(config.ai, new FileSecretStore(ctx.whydidDir));
|
|
262
|
+
if (provider.id === "noop") {
|
|
263
|
+
res.status(422).json({ error: "No LLM provider configured — set ai.provider and the matching *_API_KEY." });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const result = await askProjectQuestion(ctx, provider, projectId, conversation.id, question.trim());
|
|
268
|
+
res.json(result);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
res.status(502).json({ error: err instanceof Error ? err.message : "Failed to answer that question" });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
app.delete("/api/chat/conversations/:id", (req, res) => {
|
|
275
|
+
const conversation = ctx.db.getChatConversation(req.params.id);
|
|
276
|
+
if (!conversation || conversation.projectId !== projectId) {
|
|
277
|
+
res.status(404).json({ error: "Not found" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
ctx.db.deleteChatConversation(req.params.id);
|
|
281
|
+
res.json({ ok: true });
|
|
282
|
+
});
|
|
283
|
+
app.get("/api/context/file", (req, res) => {
|
|
284
|
+
const filePath = typeof req.query.path === "string" ? req.query.path : null;
|
|
285
|
+
// Only ever read a path this project's own context_files table already
|
|
286
|
+
// knows about — never an arbitrary query-string path — so this can't be
|
|
287
|
+
// used to read anything else off disk.
|
|
288
|
+
const tracked = filePath ? ctx.db.listContextFiles(projectId).find((f) => f.filePath === filePath) : undefined;
|
|
289
|
+
if (!tracked) {
|
|
290
|
+
res.status(404).json({ error: "Not found" });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const content = readFileSync(join(ctx.projectRoot, tracked.filePath), "utf-8");
|
|
295
|
+
res.json({ ...tracked, content });
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
res.status(404).json({ error: "Context file is tracked but missing on disk" });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
app.get("/api/project", (_req, res) => {
|
|
302
|
+
const project = ctx.db.getProject(projectId);
|
|
303
|
+
if (!project) {
|
|
304
|
+
res.status(404).json({ error: "Not found" });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
res.json(project);
|
|
308
|
+
});
|
|
309
|
+
app.get("/api/config", async (_req, res) => {
|
|
310
|
+
const config = loadConfig(ctx.whydidDir);
|
|
311
|
+
const provider = await createLLMProvider(config.ai, new FileSecretStore(ctx.whydidDir));
|
|
312
|
+
res.json({ config, resolvedProvider: provider.id });
|
|
313
|
+
});
|
|
314
|
+
app.put("/api/config", async (req, res) => {
|
|
315
|
+
const current = loadConfig(ctx.whydidDir);
|
|
316
|
+
const patch = req.body ?? {};
|
|
317
|
+
const next = {
|
|
318
|
+
...current,
|
|
319
|
+
ai: { ...current.ai, ...(patch.ai ?? {}) },
|
|
320
|
+
privacy: { ...current.privacy, ...(patch.privacy ?? {}) },
|
|
321
|
+
contextTree: { ...current.contextTree, ...(patch.contextTree ?? {}) },
|
|
322
|
+
buildInPublic: { ...current.buildInPublic, ...(patch.buildInPublic ?? {}) },
|
|
323
|
+
aiBudget: { ...current.aiBudget, ...(patch.aiBudget ?? {}) },
|
|
324
|
+
pricingOverrides: mergePricingOverrides(current.pricingOverrides, patch.pricingOverrides),
|
|
325
|
+
test: patch.test !== undefined ? patch.test : current.test,
|
|
326
|
+
};
|
|
327
|
+
saveConfig(ctx.whydidDir, next);
|
|
328
|
+
const provider = await createLLMProvider(next.ai, new FileSecretStore(ctx.whydidDir));
|
|
329
|
+
// Apply immediately to the running watcher so a Settings change takes
|
|
330
|
+
// effect on the very next checkpoint instead of silently waiting for a
|
|
331
|
+
// daemon restart the user has no reason to know is needed.
|
|
332
|
+
watcher?.setLLMProvider(provider);
|
|
333
|
+
watcher?.setPrivacy(next.privacy);
|
|
334
|
+
watcher?.setContextTreeEnabled(next.contextTree.enabled);
|
|
335
|
+
watcher?.setBuildInPublic(next.buildInPublic);
|
|
336
|
+
res.json({ config: next, resolvedProvider: provider.id });
|
|
337
|
+
});
|
|
338
|
+
app.get("/api/secrets/status", async (_req, res) => {
|
|
339
|
+
const store = new FileSecretStore(ctx.whydidDir);
|
|
340
|
+
res.json({ providers: await getSecretStatuses(store) });
|
|
341
|
+
});
|
|
342
|
+
app.put("/api/secrets", async (req, res) => {
|
|
343
|
+
const provider = typeof req.body?.provider === "string" ? req.body.provider.trim().toLowerCase() : "";
|
|
344
|
+
const value = typeof req.body?.key === "string" ? req.body.key.trim() : "";
|
|
345
|
+
if (!provider) {
|
|
346
|
+
res.status(400).json({ error: "provider is required" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!value) {
|
|
350
|
+
res.status(400).json({ error: "key is required" });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const store = new FileSecretStore(ctx.whydidDir);
|
|
354
|
+
await store.set(provider, value);
|
|
355
|
+
// Same reasoning as PUT /api/config: apply immediately to the running
|
|
356
|
+
// watcher so saving a key here takes effect on the very next checkpoint,
|
|
357
|
+
// not only after a daemon restart — but only when it's actually the
|
|
358
|
+
// provider currently selected in config; saving a key for a provider
|
|
359
|
+
// you're not using shouldn't silently switch what's active.
|
|
360
|
+
const config = loadConfig(ctx.whydidDir);
|
|
361
|
+
if (config.ai.provider === provider) {
|
|
362
|
+
watcher?.setLLMProvider(await createLLMProvider(config.ai, store));
|
|
363
|
+
}
|
|
364
|
+
res.json({ providers: await getSecretStatuses(store) });
|
|
365
|
+
});
|
|
366
|
+
app.delete("/api/secrets/:provider", async (req, res) => {
|
|
367
|
+
const provider = req.params.provider.toLowerCase();
|
|
368
|
+
const store = new FileSecretStore(ctx.whydidDir);
|
|
369
|
+
await store.delete(provider);
|
|
370
|
+
const config = loadConfig(ctx.whydidDir);
|
|
371
|
+
if (config.ai.provider === provider) {
|
|
372
|
+
watcher?.setLLMProvider(await createLLMProvider(config.ai, store));
|
|
373
|
+
}
|
|
374
|
+
res.json({ providers: await getSecretStatuses(store) });
|
|
375
|
+
});
|
|
376
|
+
app.get("/api/sessions", (req, res) => {
|
|
377
|
+
const limit = req.query.limit != null ? Math.max(1, Math.min(200, Number(req.query.limit))) : undefined;
|
|
378
|
+
const offset = req.query.offset != null ? Math.max(0, Number(req.query.offset)) : 0;
|
|
379
|
+
const total = ctx.db.countSessions(projectId);
|
|
380
|
+
const sessions = ctx.db.listSessions(projectId, { limit, offset }).map((s) => {
|
|
381
|
+
const turns = ctx.db.listTurns(s.id);
|
|
382
|
+
const lastTurn = turns[turns.length - 1];
|
|
383
|
+
return { ...s, turnCount: turns.length, lastPrompt: lastTurn?.prompt ?? null, lastTurnKind: lastTurn?.kind ?? "prompt" };
|
|
384
|
+
});
|
|
385
|
+
res.json({ sessions, total, offset, limit: limit ?? total });
|
|
386
|
+
});
|
|
387
|
+
app.get("/api/sessions/:id", (req, res) => {
|
|
388
|
+
const session = ctx.db.getSession(req.params.id);
|
|
389
|
+
if (!session) {
|
|
390
|
+
res.status(404).json({ error: "Not found" });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const turns = ctx.db.listTurns(session.id).map((t) => ({
|
|
394
|
+
...t,
|
|
395
|
+
states: ctx.db.getStatesForTurn(t.id).map((s) => ({ id: s.id, label: s.label, stateType: s.stateType, createdAt: s.createdAt })),
|
|
396
|
+
}));
|
|
397
|
+
res.json({ ...session, turns });
|
|
398
|
+
});
|
|
399
|
+
app.get("/api/export", (req, res) => {
|
|
400
|
+
const format = req.query.format ?? "json";
|
|
401
|
+
const project = ctx.db.getProject(projectId);
|
|
402
|
+
if (!project) {
|
|
403
|
+
res.status(404).json({ error: "Not found" });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const data = buildExportData(ctx.db, project);
|
|
407
|
+
const table = {
|
|
408
|
+
json: { content: exportAsJson(data), contentType: "application/json", ext: "json" },
|
|
409
|
+
markdown: { content: exportAsMarkdown(data), contentType: "text/markdown", ext: "md" },
|
|
410
|
+
html: { content: exportAsHtml(data), contentType: "text/html", ext: "html" },
|
|
411
|
+
};
|
|
412
|
+
const chosen = table[format];
|
|
413
|
+
if (!chosen) {
|
|
414
|
+
res.status(400).json({ error: "format must be json, markdown, or html" });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
res.setHeader("Content-Disposition", `attachment; filename="whydid-export.${chosen.ext}"`);
|
|
418
|
+
res.type(chosen.contentType).send(chosen.content);
|
|
419
|
+
});
|
|
420
|
+
app.get("/api/states/:id", (req, res) => {
|
|
421
|
+
const state = ctx.db.getState(req.params.id);
|
|
422
|
+
if (!state) {
|
|
423
|
+
res.status(404).json({ error: "Not found" });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
res.json({
|
|
427
|
+
...state,
|
|
428
|
+
fileChanges: ctx.db.getFileChanges(state.id),
|
|
429
|
+
annotation: ctx.db.getAnnotation(state.id) ?? null,
|
|
430
|
+
resumedFromCompaction: resumedFromCompaction(state.turnId),
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
app.get("/api/states/:id/diff/*", (req, res) => {
|
|
434
|
+
const filePath = req.params[0];
|
|
435
|
+
const changes = ctx.db.getFileChanges(req.params.id);
|
|
436
|
+
const change = changes.find((c) => c.filePath === filePath);
|
|
437
|
+
if (!change) {
|
|
438
|
+
res.status(404).json({ error: "No such file change in this state" });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
res.json(computeFileDiff(ctx.store, change));
|
|
442
|
+
});
|
|
443
|
+
app.put("/api/states/:id/milestone", (req, res) => {
|
|
444
|
+
const state = ctx.db.getState(req.params.id);
|
|
445
|
+
if (!state) {
|
|
446
|
+
res.status(404).json({ error: "Not found" });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const label = req.body?.label;
|
|
450
|
+
if (label !== null && typeof label !== "string") {
|
|
451
|
+
res.status(400).json({ error: "label must be a string or null" });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (typeof label === "string" && label.trim() === "") {
|
|
455
|
+
res.status(400).json({ error: "label must not be empty — pass null to clear it" });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
ctx.db.setStateMilestone(req.params.id, label);
|
|
459
|
+
res.json({ stateId: req.params.id, milestoneLabel: label });
|
|
460
|
+
});
|
|
461
|
+
app.post("/api/states/:id/restore", async (req, res) => {
|
|
462
|
+
const result = await restoreState(ctx, projectId, req.params.id);
|
|
463
|
+
res.json(result);
|
|
464
|
+
});
|
|
465
|
+
app.post("/api/states/:id/restore-files", async (req, res) => {
|
|
466
|
+
const filePaths = req.body?.filePaths;
|
|
467
|
+
if (!Array.isArray(filePaths) || filePaths.length === 0 || !filePaths.every((p) => typeof p === "string")) {
|
|
468
|
+
res.status(400).json({ error: "filePaths must be a non-empty array of strings" });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const result = await restoreFiles(ctx, projectId, req.params.id, filePaths);
|
|
473
|
+
res.json(result);
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
app.post("/api/states/:id/fork", async (req, res) => {
|
|
480
|
+
const name = req.body?.name;
|
|
481
|
+
if (!name) {
|
|
482
|
+
res.status(400).json({ error: "name is required" });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const result = await forkState(ctx, projectId, req.params.id, name);
|
|
486
|
+
res.json(result);
|
|
487
|
+
});
|
|
488
|
+
app.get("/api/files/*/history", (req, res) => {
|
|
489
|
+
const filePath = req.params[0];
|
|
490
|
+
res.json(getFileHistory(ctx.db, projectId, filePath));
|
|
491
|
+
});
|
|
492
|
+
app.post("/api/files/*/agent-prompt", async (req, res) => {
|
|
493
|
+
const filePath = req.params[0];
|
|
494
|
+
const mode = (req.body?.mode ?? "revert");
|
|
495
|
+
const stateId = req.body?.stateId;
|
|
496
|
+
const continueInstruction = req.body?.instruction;
|
|
497
|
+
if (mode !== "revert" && mode !== "continue") {
|
|
498
|
+
res.status(400).json({ error: 'mode must be "revert" or "continue"' });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (mode === "continue" && !continueInstruction) {
|
|
502
|
+
res.status(400).json({ error: "instruction is required for mode continue" });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const history = getFileHistory(ctx.db, projectId, filePath);
|
|
506
|
+
if (history.length === 0) {
|
|
507
|
+
res.status(404).json({ error: "No recorded history for this file" });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const targetIndex = stateId ? history.findIndex((h) => h.stateId === stateId) : history.length - 1;
|
|
511
|
+
if (targetIndex === -1) {
|
|
512
|
+
res.status(404).json({ error: "That state did not change this file" });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const targetEntry = history[targetIndex];
|
|
516
|
+
const laterEntries = history.slice(targetIndex + 1);
|
|
517
|
+
const config = loadConfig(ctx.whydidDir);
|
|
518
|
+
const provider = await createLLMProvider(config.ai, new FileSecretStore(ctx.whydidDir));
|
|
519
|
+
if (provider.id === "noop") {
|
|
520
|
+
res.status(422).json({ error: "No LLM provider configured — set ai.provider and the matching *_API_KEY." });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const change = ctx.db.getFileChanges(targetEntry.stateId).find((c) => c.filePath === filePath);
|
|
524
|
+
const targetDiff = change ? computeFileDiff(ctx.store, change).unified : "(diff unavailable)";
|
|
525
|
+
try {
|
|
526
|
+
const prompt = await generateAgentPrompt(provider, { filePath, targetEntry, laterEntries, targetDiff, mode, continueInstruction });
|
|
527
|
+
res.json({ prompt });
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
res.status(502).json({ error: err instanceof Error ? err.message : "Prompt generation failed" });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
// A request under /api/ that didn't match any route above is a real 404 —
|
|
534
|
+
// it must never fall through to the SPA catch-all below, which would
|
|
535
|
+
// otherwise silently hand back index.html for it. The dashboard's fetch
|
|
536
|
+
// wrapper always calls res.json() on the response; getting HTML back
|
|
537
|
+
// produces exactly the "Unexpected token '<'" parse error this guards
|
|
538
|
+
// against, for literally any endpoint typo or stale client/server mismatch.
|
|
539
|
+
app.use("/api", (_req, res) => {
|
|
540
|
+
res.status(404).json({ error: "Not found" });
|
|
541
|
+
});
|
|
542
|
+
const dashboardDist = join(PACKAGE_ROOT, "dashboard", "dist");
|
|
543
|
+
if (existsSync(dashboardDist)) {
|
|
544
|
+
app.use(express.static(dashboardDist));
|
|
545
|
+
app.get("*", (_req, res) => res.sendFile(join(dashboardDist, "index.html")));
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
app.get("/", (_req, res) => {
|
|
549
|
+
res.type("html").send("<h1>whydid</h1><p>Dashboard not built yet — run <code>npm run dashboard:build</code>.</p>");
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return app;
|
|
553
|
+
}
|
|
554
|
+
//# sourceMappingURL=server.js.map
|