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.
Files changed (84) hide show
  1. package/README.md +232 -0
  2. package/dashboard/dist/android-chrome-192x192.png +0 -0
  3. package/dashboard/dist/android-chrome-512x512.png +0 -0
  4. package/dashboard/dist/apple-touch-icon.png +0 -0
  5. package/dashboard/dist/assets/index-DImKqodm.js +69 -0
  6. package/dashboard/dist/assets/index-mHXgqqkj.css +1 -0
  7. package/dashboard/dist/favicon-16x16.png +0 -0
  8. package/dashboard/dist/favicon-32x32.png +0 -0
  9. package/dashboard/dist/favicon.ico +0 -0
  10. package/dashboard/dist/index.html +24 -0
  11. package/dashboard/dist/logo-mark.svg +8 -0
  12. package/dashboard/dist/site.webmanifest +11 -0
  13. package/dist/adapters/claude-code.js +168 -0
  14. package/dist/adapters/hook-events.js +86 -0
  15. package/dist/adapters/sync-claude-code.js +85 -0
  16. package/dist/cli/ai-budget-warning.js +19 -0
  17. package/dist/cli/commands/agent-event.js +80 -0
  18. package/dist/cli/commands/checkpoint.js +36 -0
  19. package/dist/cli/commands/context.js +27 -0
  20. package/dist/cli/commands/daemon.js +139 -0
  21. package/dist/cli/commands/export.js +36 -0
  22. package/dist/cli/commands/init.js +63 -0
  23. package/dist/cli/commands/key.js +65 -0
  24. package/dist/cli/commands/license.js +57 -0
  25. package/dist/cli/commands/milestone.js +64 -0
  26. package/dist/cli/commands/prompt.js +75 -0
  27. package/dist/cli/commands/recap.js +48 -0
  28. package/dist/cli/commands/restore-fork.js +101 -0
  29. package/dist/cli/commands/status.js +26 -0
  30. package/dist/cli/commands/test.js +33 -0
  31. package/dist/cli/commands/timeline.js +23 -0
  32. package/dist/cli/commands/why.js +71 -0
  33. package/dist/cli/context.js +48 -0
  34. package/dist/cli/index.js +77 -0
  35. package/dist/cli/license-gate.js +26 -0
  36. package/dist/cli/preflight.js +28 -0
  37. package/dist/core/agent-context.js +46 -0
  38. package/dist/core/agent-hooks.js +68 -0
  39. package/dist/core/ai-budget.js +27 -0
  40. package/dist/core/chat-retrieval.js +36 -0
  41. package/dist/core/context-tree.js +135 -0
  42. package/dist/core/diff.js +30 -0
  43. package/dist/core/engine.js +321 -0
  44. package/dist/core/export.js +98 -0
  45. package/dist/core/file-history.js +31 -0
  46. package/dist/core/manifest.js +102 -0
  47. package/dist/core/recap.js +49 -0
  48. package/dist/core/test-runner.js +23 -0
  49. package/dist/daemon/listen.js +26 -0
  50. package/dist/daemon/pidfile.js +36 -0
  51. package/dist/daemon/server.js +554 -0
  52. package/dist/db/database.js +631 -0
  53. package/dist/db/schema.js +186 -0
  54. package/dist/git/git.js +22 -0
  55. package/dist/licensing/config.js +28 -0
  56. package/dist/licensing/license-client.js +45 -0
  57. package/dist/licensing/license-manager.js +89 -0
  58. package/dist/licensing/license-payload.js +12 -0
  59. package/dist/licensing/license-store.js +96 -0
  60. package/dist/licensing/machine-id.js +78 -0
  61. package/dist/licensing/secret-store.js +102 -0
  62. package/dist/licensing/verify-license.js +20 -0
  63. package/dist/llm/agent-prompt.js +36 -0
  64. package/dist/llm/annotate-checkpoint.js +67 -0
  65. package/dist/llm/annotate.js +11 -0
  66. package/dist/llm/anthropic-provider.js +97 -0
  67. package/dist/llm/chat.js +35 -0
  68. package/dist/llm/community-pricing.js +75 -0
  69. package/dist/llm/factory.js +17 -0
  70. package/dist/llm/http-error.js +25 -0
  71. package/dist/llm/noop-provider.js +19 -0
  72. package/dist/llm/openai-provider.js +85 -0
  73. package/dist/llm/pricing.js +55 -0
  74. package/dist/llm/prompt.js +103 -0
  75. package/dist/llm/provider.js +2 -0
  76. package/dist/llm/schema.js +49 -0
  77. package/dist/llm/social-post.js +49 -0
  78. package/dist/objects/store.js +31 -0
  79. package/dist/shared/config.js +45 -0
  80. package/dist/shared/redaction.js +18 -0
  81. package/dist/shared/types.js +2 -0
  82. package/dist/watcher/ignore-rules.js +92 -0
  83. package/dist/watcher/watcher.js +249 -0
  84. 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