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,631 @@
1
+ import { createRequire } from "node:module";
2
+ import { SCHEMA_SQL } from "./schema.js";
3
+ const nodeRequire = createRequire(import.meta.url);
4
+ const { DatabaseSync } = nodeRequire("node:sqlite");
5
+ function rowToSocialPost(row) {
6
+ return {
7
+ id: row.id, projectId: row.project_id, sessionId: row.session_id, platform: row.platform,
8
+ content: row.content, source: row.source, pinned: row.pinned === 1, createdAt: row.created_at,
9
+ model: row.model ?? null, inputTokens: row.input_tokens ?? null, outputTokens: row.output_tokens ?? null,
10
+ };
11
+ }
12
+ function rowToChatConversation(row) {
13
+ return { id: row.id, projectId: row.project_id, title: row.title, createdAt: row.created_at, updatedAt: row.updated_at };
14
+ }
15
+ function rowToChatMessage(row) {
16
+ return {
17
+ id: row.id, conversationId: row.conversation_id, role: row.role, content: row.content,
18
+ citedStateIds: row.cited_state_ids_json ? JSON.parse(row.cited_state_ids_json) : [],
19
+ model: row.model ?? null, inputTokens: row.input_tokens ?? null, outputTokens: row.output_tokens ?? null, createdAt: row.created_at,
20
+ };
21
+ }
22
+ function buildStateFilter(projectId, opts) {
23
+ const clauses = ["s.project_id = ?"];
24
+ const params = [projectId];
25
+ if (opts.risk) {
26
+ clauses.push("COALESCE(a.risk_level, s.risk_level) = ?");
27
+ params.push(opts.risk);
28
+ }
29
+ if (opts.search && opts.search.trim() !== "") {
30
+ clauses.push("(LOWER(COALESCE(a.summary,'')) LIKE ? OR LOWER(COALESCE(a.reason,'')) LIKE ? OR LOWER(COALESCE(s.label,'')) LIKE ? OR LOWER(s.id) LIKE ?)");
31
+ const q = `%${opts.search.trim().toLowerCase()}%`;
32
+ params.push(q, q, q, q);
33
+ }
34
+ return { where: clauses.join(" AND "), params };
35
+ }
36
+ export class WhydidDatabase {
37
+ raw;
38
+ constructor(dbPath) {
39
+ this.raw = new DatabaseSync(dbPath);
40
+ this.raw.exec("PRAGMA journal_mode = WAL;");
41
+ this.raw.exec("PRAGMA foreign_keys = ON;");
42
+ this.raw.exec(SCHEMA_SQL);
43
+ this.runMigrations();
44
+ }
45
+ // CREATE TABLE IF NOT EXISTS is a no-op for a table that already exists,
46
+ // so a column added to schema.ts after someone's .whydid/db.sqlite was
47
+ // first created needs an explicit, idempotent migration — this project
48
+ // doesn't otherwise have a migration framework, so this stays a short
49
+ // list of "add this column if it isn't already there" steps.
50
+ runMigrations() {
51
+ this.addColumnIfMissing("annotations", "input_tokens", "INTEGER");
52
+ this.addColumnIfMissing("annotations", "output_tokens", "INTEGER");
53
+ this.addColumnIfMissing("social_posts", "model", "TEXT");
54
+ this.addColumnIfMissing("social_posts", "input_tokens", "INTEGER");
55
+ this.addColumnIfMissing("social_posts", "output_tokens", "INTEGER");
56
+ this.addColumnIfMissing("chat_messages", "model", "TEXT");
57
+ this.addColumnIfMissing("chat_messages", "input_tokens", "INTEGER");
58
+ this.addColumnIfMissing("chat_messages", "output_tokens", "INTEGER");
59
+ this.addColumnIfMissing("states", "milestone_label", "TEXT");
60
+ this.addColumnIfMissing("turns", "kind", "TEXT NOT NULL DEFAULT 'prompt'");
61
+ }
62
+ addColumnIfMissing(table, column, definition) {
63
+ const cols = this.raw.prepare(`PRAGMA table_info(${table})`).all();
64
+ if (!cols.some((c) => c.name === column)) {
65
+ this.raw.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
66
+ }
67
+ }
68
+ close() {
69
+ this.raw.close();
70
+ }
71
+ // node:sqlite runs every statement in its own implicit transaction
72
+ // (autocommit) unless wrapped in an explicit BEGIN/COMMIT — meaning a
73
+ // bulk insert of one row per statement pays a full fsync per row. Wrapping
74
+ // the batch in one transaction turns N fsyncs into 1, with identical rows
75
+ // ending up in the table either way.
76
+ transaction(fn) {
77
+ this.raw.exec("BEGIN");
78
+ try {
79
+ const result = fn();
80
+ this.raw.exec("COMMIT");
81
+ return result;
82
+ }
83
+ catch (err) {
84
+ this.raw.exec("ROLLBACK");
85
+ throw err;
86
+ }
87
+ }
88
+ insertProject(p) {
89
+ const now = new Date().toISOString();
90
+ this.raw
91
+ .prepare(`INSERT INTO projects (id, name, root_path, git_root, head_state_id, created_at, updated_at)
92
+ VALUES (?, ?, ?, ?, NULL, ?, ?)`)
93
+ .run(p.id, p.name, p.rootPath, p.gitRoot, now, now);
94
+ }
95
+ getProject(id) {
96
+ const row = this.raw.prepare(`SELECT * FROM projects WHERE id = ?`).get(id);
97
+ if (!row)
98
+ return undefined;
99
+ return {
100
+ id: row.id, name: row.name, rootPath: row.root_path, gitRoot: row.git_root,
101
+ headStateId: row.head_state_id, createdAt: row.created_at, updatedAt: row.updated_at,
102
+ };
103
+ }
104
+ insertState(s) {
105
+ this.raw
106
+ .prepare(`INSERT INTO states
107
+ (id, project_id, parent_state_id, session_id, turn_id, state_type, label, summary,
108
+ reason, decision_notes, risk_level, created_at, git_commit_sha, working_tree_hash)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
110
+ .run(s.id, s.projectId, s.parentStateId, s.sessionId, s.turnId, s.stateType, s.label, s.summary, s.reason, s.decisionNotes, s.riskLevel, s.createdAt, s.gitCommitSha, s.workingTreeHash);
111
+ }
112
+ rowToState(row) {
113
+ return {
114
+ id: row.id, projectId: row.project_id, parentStateId: row.parent_state_id,
115
+ sessionId: row.session_id, turnId: row.turn_id, stateType: row.state_type,
116
+ label: row.label, summary: row.summary, reason: row.reason,
117
+ decisionNotes: row.decision_notes, riskLevel: row.risk_level, createdAt: row.created_at,
118
+ gitCommitSha: row.git_commit_sha, workingTreeHash: row.working_tree_hash,
119
+ milestoneLabel: row.milestone_label ?? null,
120
+ };
121
+ }
122
+ getState(id) {
123
+ const row = this.raw.prepare(`SELECT * FROM states WHERE id = ?`).get(id);
124
+ return row ? this.rowToState(row) : undefined;
125
+ }
126
+ /** Tags (or untags, with label = null) a state as a milestone — a founder-chosen marker like "v1 launch" or "before pricing change" so it stands out in the timeline later. */
127
+ setStateMilestone(id, label) {
128
+ this.raw.prepare(`UPDATE states SET milestone_label = ? WHERE id = ?`).run(label, id);
129
+ }
130
+ listMilestones(projectId) {
131
+ const rows = this.raw
132
+ .prepare(`SELECT * FROM states WHERE project_id = ? AND milestone_label IS NOT NULL ORDER BY created_at DESC`)
133
+ .all(projectId);
134
+ return rows.map((r) => this.rowToState(r));
135
+ }
136
+ getHeadState(projectId) {
137
+ const project = this.getProject(projectId);
138
+ if (!project?.headStateId)
139
+ return undefined;
140
+ return this.getState(project.headStateId);
141
+ }
142
+ setHeadState(projectId, stateId) {
143
+ this.raw
144
+ .prepare(`UPDATE projects SET head_state_id = ?, updated_at = ? WHERE id = ?`)
145
+ .run(stateId, new Date().toISOString(), projectId);
146
+ }
147
+ listStates(projectId, opts = {}) {
148
+ const { where, params } = buildStateFilter(projectId, opts);
149
+ let sql = `SELECT s.* FROM states s LEFT JOIN annotations a ON a.id = (
150
+ SELECT id FROM annotations WHERE state_id = s.id ORDER BY created_at DESC LIMIT 1
151
+ ) WHERE ${where} ORDER BY s.created_at DESC, s.id DESC`;
152
+ if (opts.limit != null) {
153
+ sql += ` LIMIT ? OFFSET ?`;
154
+ params.push(opts.limit, opts.offset ?? 0);
155
+ }
156
+ const rows = this.raw.prepare(sql).all(...params);
157
+ return rows.map((r) => this.rowToState(r));
158
+ }
159
+ countStates(projectId, opts = {}) {
160
+ const { where, params } = buildStateFilter(projectId, opts);
161
+ const sql = `SELECT COUNT(*) as n FROM states s LEFT JOIN annotations a ON a.id = (
162
+ SELECT id FROM annotations WHERE state_id = s.id ORDER BY created_at DESC LIMIT 1
163
+ ) WHERE ${where}`;
164
+ const row = this.raw.prepare(sql).get(...params);
165
+ return row.n;
166
+ }
167
+ /** Real checkpoints (excludes the initial state) per calendar day, oldest first, since sinceIso. Days with zero activity are simply absent. */
168
+ getDailyCheckpointCounts(projectId, sinceIso) {
169
+ const rows = this.raw
170
+ .prepare(`SELECT substr(created_at, 1, 10) as day, COUNT(*) as n
171
+ FROM states
172
+ WHERE project_id = ? AND state_type != 'initial' AND created_at >= ?
173
+ GROUP BY day ORDER BY day ASC`)
174
+ .all(projectId, sinceIso);
175
+ return rows.map((r) => ({ date: r.day, count: r.n }));
176
+ }
177
+ /** All-time totals for the "progress so far" showcase — not paginated/filtered. */
178
+ getProjectTotals(projectId) {
179
+ const checkpoints = this.raw
180
+ .prepare(`SELECT COUNT(*) as n FROM states WHERE project_id = ? AND state_type != 'initial'`)
181
+ .get(projectId);
182
+ const sessions = this.raw.prepare(`SELECT COUNT(*) as n FROM sessions WHERE project_id = ?`).get(projectId);
183
+ return { totalCheckpoints: checkpoints.n, totalSessions: sessions.n };
184
+ }
185
+ /**
186
+ * State ids with a *generated* annotation, newest first, capped at limit —
187
+ * scoped to a directory's immediate file children, or project-wide.
188
+ * dir === null -> any file, anywhere in the project (for the project summary)
189
+ * dir === "" -> files directly at the project root (no "/" in their path)
190
+ * dir === "src/auth" -> files whose immediate directory is exactly "src/auth"
191
+ * (deeper-nested files like src/auth/handlers/x.ts belong to their own
192
+ * "src/auth/handlers" bucket, not this one — matches how CONTEXT.md files
193
+ * are generated, one per immediate directory)
194
+ */
195
+ findAnnotatedStateIdsForDirectory(projectId, dir, limit) {
196
+ let sql = `
197
+ SELECT DISTINCT s.id, s.created_at
198
+ FROM states s
199
+ JOIN annotations a ON a.id = (
200
+ SELECT id FROM annotations WHERE state_id = s.id ORDER BY created_at DESC LIMIT 1
201
+ )`;
202
+ const clauses = [`s.project_id = ?`, `a.status = 'generated'`];
203
+ const params = [projectId];
204
+ if (dir !== null) {
205
+ sql += ` JOIN file_changes fc ON fc.state_id = s.id`;
206
+ if (dir === "") {
207
+ clauses.push(`fc.file_path NOT LIKE '%/%'`);
208
+ }
209
+ else {
210
+ clauses.push(`fc.file_path LIKE ? AND fc.file_path NOT LIKE ?`);
211
+ params.push(`${dir}/%`, `${dir}/%/%`);
212
+ }
213
+ }
214
+ sql += ` WHERE ${clauses.join(" AND ")} ORDER BY s.created_at DESC LIMIT ?`;
215
+ params.push(limit);
216
+ const rows = this.raw.prepare(sql).all(...params);
217
+ return rows.map((r) => r.id);
218
+ }
219
+ upsertContextFile(entry) {
220
+ this.raw
221
+ .prepare(`INSERT INTO context_files (project_id, dir_path, file_path, checkpoint_count, updated_at)
222
+ VALUES (?, ?, ?, ?, ?)
223
+ ON CONFLICT(project_id, file_path) DO UPDATE SET
224
+ dir_path = excluded.dir_path, checkpoint_count = excluded.checkpoint_count, updated_at = excluded.updated_at`)
225
+ .run(entry.projectId, entry.dirPath, entry.filePath, entry.checkpointCount, entry.updatedAt);
226
+ }
227
+ listContextFiles(projectId) {
228
+ const rows = this.raw
229
+ .prepare(`SELECT dir_path, file_path, checkpoint_count, updated_at FROM context_files WHERE project_id = ? ORDER BY updated_at DESC`)
230
+ .all(projectId);
231
+ return rows.map((r) => ({ dirPath: r.dir_path, filePath: r.file_path, checkpointCount: r.checkpoint_count, updatedAt: r.updated_at }));
232
+ }
233
+ insertSocialPost(p) {
234
+ this.raw
235
+ .prepare(`INSERT INTO social_posts (id, project_id, session_id, platform, content, source, pinned, created_at, model, input_tokens, output_tokens)
236
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`)
237
+ .run(p.id, p.projectId, p.sessionId, p.platform, p.content, p.source, new Date().toISOString(), p.model ?? null, p.inputTokens ?? null, p.outputTokens ?? null);
238
+ }
239
+ listSocialPosts(projectId) {
240
+ const rows = this.raw
241
+ .prepare(`SELECT * FROM social_posts WHERE project_id = ? ORDER BY created_at DESC`)
242
+ .all(projectId);
243
+ return rows.map(rowToSocialPost);
244
+ }
245
+ getSocialPost(id) {
246
+ const row = this.raw.prepare(`SELECT * FROM social_posts WHERE id = ?`).get(id);
247
+ return row ? rowToSocialPost(row) : undefined;
248
+ }
249
+ setSocialPostPinned(id, pinned) {
250
+ this.raw.prepare(`UPDATE social_posts SET pinned = ? WHERE id = ?`).run(pinned ? 1 : 0, id);
251
+ }
252
+ deleteSocialPost(id) {
253
+ this.raw.prepare(`DELETE FROM social_posts WHERE id = ?`).run(id);
254
+ }
255
+ /** Deletes unpinned drafts older than retentionDays. retentionDays <= 0 is a no-op (auto-delete disabled). Returns the number removed. */
256
+ pruneOldSocialPosts(projectId, retentionDays) {
257
+ if (retentionDays <= 0)
258
+ return 0;
259
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
260
+ const result = this.raw
261
+ .prepare(`DELETE FROM social_posts WHERE project_id = ? AND pinned = 0 AND created_at < ?`)
262
+ .run(projectId, cutoff);
263
+ return Number(result.changes);
264
+ }
265
+ // ---- Project chat ----
266
+ insertChatConversation(c) {
267
+ const now = new Date().toISOString();
268
+ this.raw
269
+ .prepare(`INSERT INTO chat_conversations (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`)
270
+ .run(c.id, c.projectId, c.title, now, now);
271
+ }
272
+ listChatConversations(projectId) {
273
+ const rows = this.raw
274
+ .prepare(`SELECT * FROM chat_conversations WHERE project_id = ? ORDER BY updated_at DESC`)
275
+ .all(projectId);
276
+ return rows.map(rowToChatConversation);
277
+ }
278
+ getChatConversation(id) {
279
+ const row = this.raw.prepare(`SELECT * FROM chat_conversations WHERE id = ?`).get(id);
280
+ return row ? rowToChatConversation(row) : undefined;
281
+ }
282
+ touchChatConversation(id) {
283
+ this.raw.prepare(`UPDATE chat_conversations SET updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
284
+ }
285
+ setChatConversationTitle(id, title) {
286
+ this.raw.prepare(`UPDATE chat_conversations SET title = ? WHERE id = ?`).run(title, id);
287
+ }
288
+ deleteChatConversation(id) {
289
+ this.transaction(() => {
290
+ this.raw.prepare(`DELETE FROM chat_messages WHERE conversation_id = ?`).run(id);
291
+ this.raw.prepare(`DELETE FROM chat_conversations WHERE id = ?`).run(id);
292
+ });
293
+ }
294
+ insertChatMessage(m) {
295
+ this.raw
296
+ .prepare(`INSERT INTO chat_messages (id, conversation_id, role, content, cited_state_ids_json, model, input_tokens, output_tokens, created_at)
297
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
298
+ .run(m.id, m.conversationId, m.role, m.content, m.citedStateIds ? JSON.stringify(m.citedStateIds) : null, m.model ?? null, m.inputTokens ?? null, m.outputTokens ?? null, new Date().toISOString());
299
+ }
300
+ listChatMessages(conversationId) {
301
+ const rows = this.raw
302
+ .prepare(`SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`)
303
+ .all(conversationId);
304
+ return rows.map(rowToChatMessage);
305
+ }
306
+ /** Indexes (or re-indexes) one state's annotation content for chat retrieval. Call only for generated annotations — there's nothing useful to search otherwise. */
307
+ indexAnnotationForSearch(stateId, projectId, text) {
308
+ this.raw.prepare(`DELETE FROM annotation_search WHERE state_id = ?`).run(stateId);
309
+ this.raw.prepare(`INSERT INTO annotation_search (state_id, project_id, text) VALUES (?, ?, ?)`).run(stateId, projectId, text);
310
+ }
311
+ /**
312
+ * Keyword search over indexed annotations, safe against arbitrary user
313
+ * input: terms are extracted as plain words and each wrapped as an FTS5
314
+ * quoted phrase (escaping embedded quotes), joined with OR — so anything
315
+ * a free-text chat question contains (colons, parens, "-", etc., all
316
+ * meaningful in raw FTS5 query syntax) can never produce a malformed
317
+ * MATCH expression or an unintended AND/NOT.
318
+ */
319
+ searchAnnotations(projectId, query, limit) {
320
+ const terms = [...new Set(query.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [])].slice(0, 12);
321
+ if (terms.length === 0)
322
+ return [];
323
+ const matchExpr = terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
324
+ const rows = this.raw
325
+ .prepare(`SELECT state_id FROM annotation_search WHERE project_id = ? AND annotation_search MATCH ? ORDER BY rank LIMIT ?`)
326
+ .all(projectId, matchExpr, limit);
327
+ return rows.map((r) => r.state_id);
328
+ }
329
+ insertFileChanges(stateId, changes) {
330
+ const stmt = this.raw.prepare(`INSERT INTO file_changes (id, state_id, file_path, change_type, before_hash, after_hash, lines_added, lines_removed)
331
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
332
+ this.transaction(() => {
333
+ for (const c of changes) {
334
+ stmt.run(`${stateId}:${c.filePath}`, stateId, c.filePath, c.changeType, c.beforeHash, c.afterHash, c.linesAdded, c.linesRemoved);
335
+ }
336
+ });
337
+ }
338
+ getFileChanges(stateId) {
339
+ const rows = this.raw.prepare(`SELECT * FROM file_changes WHERE state_id = ?`).all(stateId);
340
+ return rows.map((r) => ({
341
+ id: r.id, stateId: r.state_id, filePath: r.file_path, changeType: r.change_type,
342
+ beforeHash: r.before_hash, afterHash: r.after_hash, linesAdded: r.lines_added,
343
+ linesRemoved: r.lines_removed,
344
+ }));
345
+ }
346
+ insertStateManifest(stateId, manifest) {
347
+ const stmt = this.raw.prepare(`INSERT INTO state_manifest (state_id, file_path, content_hash, size_bytes) VALUES (?, ?, ?, ?)`);
348
+ this.transaction(() => {
349
+ for (const entry of manifest) {
350
+ stmt.run(stateId, entry.filePath, entry.contentHash, entry.sizeBytes);
351
+ }
352
+ });
353
+ }
354
+ /** Whether a full manifest snapshot ("keyframe") is stored for this exact state — vs. a state whose manifest must be reconstructed from its ancestors' deltas. */
355
+ hasFullManifest(stateId) {
356
+ const row = this.raw.prepare(`SELECT 1 FROM state_manifest WHERE state_id = ? LIMIT 1`).get(stateId);
357
+ return row !== undefined;
358
+ }
359
+ readFullManifestRows(stateId) {
360
+ const rows = this.raw
361
+ .prepare(`SELECT file_path, content_hash, size_bytes FROM state_manifest WHERE state_id = ? ORDER BY file_path`)
362
+ .all(stateId);
363
+ return rows.map((r) => ({ filePath: r.file_path, contentHash: r.content_hash, sizeBytes: r.size_bytes }));
364
+ }
365
+ /**
366
+ * Returns the full file manifest for a state. Not every state has one
367
+ * stored directly — createCheckpoint only stores a full snapshot
368
+ * ("keyframe") periodically (see MANIFEST_KEYFRAME_INTERVAL in engine.ts)
369
+ * to avoid writing every tracked file's row on every single checkpoint,
370
+ * which at scale (tens of thousands of files, thousands of checkpoints
371
+ * over a project's life) made state_manifest the dominant driver of
372
+ * .whydid's on-disk size. For a state with no stored snapshot, this walks
373
+ * up the parent chain collecting file_changes (already stored for every
374
+ * state regardless) until it finds an ancestor that does have one, then
375
+ * replays those deltas oldest-first — the initial state always has a
376
+ * full manifest, so the walk is guaranteed to terminate.
377
+ *
378
+ * sizeBytes on a reconstructed (non-keyframe) entry is not accurate — it
379
+ * isn't consumed by anything correctness-critical (restore/fork apply by
380
+ * contentHash only; diffing and the state fingerprint are hash-based too)
381
+ * so recomputing it from the object store on every replay isn't worth the
382
+ * extra disk read.
383
+ */
384
+ getStateManifest(stateId) {
385
+ if (this.hasFullManifest(stateId))
386
+ return this.readFullManifestRows(stateId);
387
+ const deltaChain = []; // nearest-to-stateId first
388
+ let cur = stateId;
389
+ let keyframeRows = [];
390
+ while (cur) {
391
+ if (this.hasFullManifest(cur)) {
392
+ keyframeRows = this.readFullManifestRows(cur);
393
+ break;
394
+ }
395
+ deltaChain.push(cur);
396
+ const parent = this.raw.prepare(`SELECT parent_state_id FROM states WHERE id = ?`).get(cur);
397
+ cur = parent?.parent_state_id ?? undefined;
398
+ }
399
+ const manifestMap = new Map(keyframeRows.map((e) => [e.filePath, e]));
400
+ for (const sid of deltaChain.reverse()) {
401
+ const changes = this.raw.prepare(`SELECT file_path, change_type, after_hash FROM file_changes WHERE state_id = ?`).all(sid);
402
+ for (const c of changes) {
403
+ if (c.change_type === "deleted") {
404
+ manifestMap.delete(c.file_path);
405
+ }
406
+ else {
407
+ manifestMap.set(c.file_path, { filePath: c.file_path, contentHash: c.after_hash, sizeBytes: -1 });
408
+ }
409
+ }
410
+ }
411
+ return [...manifestMap.values()].sort((a, b) => a.filePath.localeCompare(b.filePath));
412
+ }
413
+ insertAnnotation(a) {
414
+ this.raw
415
+ .prepare(`INSERT INTO annotations (id, state_id, summary, reason, decisions_json, risk_level, generated_by, model, status, created_at, input_tokens, output_tokens)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
417
+ .run(a.id, a.stateId, a.summary, a.reason, JSON.stringify(a.decisions), a.riskLevel, a.generatedBy, a.model, a.status, a.createdAt ?? new Date().toISOString(), a.inputTokens ?? null, a.outputTokens ?? null);
418
+ }
419
+ getAnnotation(stateId) {
420
+ const row = this.raw
421
+ .prepare(`SELECT * FROM annotations WHERE state_id = ? ORDER BY created_at DESC LIMIT 1`)
422
+ .get(stateId);
423
+ if (!row)
424
+ return undefined;
425
+ return {
426
+ id: row.id, stateId: row.state_id, summary: row.summary, reason: row.reason,
427
+ decisions: JSON.parse(row.decisions_json ?? "[]"), riskLevel: row.risk_level,
428
+ generatedBy: row.generated_by, model: row.model, status: row.status, createdAt: row.created_at,
429
+ inputTokens: row.input_tokens ?? null, outputTokens: row.output_tokens ?? null,
430
+ };
431
+ }
432
+ /** Per-day input/output token totals across all generated annotations, oldest first — the data behind the AI-cost visualization. */
433
+ // Every LLM call whydid itself makes — checkpoint annotation, build-in-public
434
+ // drafting, and project chat — records real usage on its own table (they
435
+ // each already had a natural row to attach it to). This unions all three
436
+ // so the AI Usage page reflects total real spend, not just annotations.
437
+ static TOKEN_USAGE_SOURCES = `
438
+ SELECT a.created_at as created_at, a.model as model, a.input_tokens as input_tokens, a.output_tokens as output_tokens, s.project_id as project_id
439
+ FROM annotations a JOIN states s ON s.id = a.state_id WHERE a.input_tokens IS NOT NULL
440
+ UNION ALL
441
+ SELECT created_at, model, input_tokens, output_tokens, project_id FROM social_posts WHERE input_tokens IS NOT NULL
442
+ UNION ALL
443
+ SELECT m.created_at as created_at, m.model as model, m.input_tokens as input_tokens, m.output_tokens as output_tokens, c.project_id as project_id
444
+ FROM chat_messages m JOIN chat_conversations c ON c.id = m.conversation_id WHERE m.input_tokens IS NOT NULL
445
+ `;
446
+ getDailyTokenUsage(projectId, sinceIso) {
447
+ const rows = this.raw
448
+ .prepare(`SELECT substr(created_at, 1, 10) as day, SUM(COALESCE(input_tokens, 0)) as input_tokens, SUM(COALESCE(output_tokens, 0)) as output_tokens
449
+ FROM (${WhydidDatabase.TOKEN_USAGE_SOURCES}) usage
450
+ WHERE project_id = ? AND created_at >= ?
451
+ GROUP BY day ORDER BY day ASC`)
452
+ .all(projectId, sinceIso);
453
+ return rows.map((r) => ({ date: r.day, inputTokens: r.input_tokens, outputTokens: r.output_tokens }));
454
+ }
455
+ /** All-time token totals, broken down per model — pairs with a pricing table to estimate spend, since cost varies per model. */
456
+ getTokenUsageByModel(projectId) {
457
+ const rows = this.raw
458
+ .prepare(`SELECT COALESCE(model, 'unknown') as model, COUNT(*) as calls,
459
+ SUM(COALESCE(input_tokens, 0)) as input_tokens, SUM(COALESCE(output_tokens, 0)) as output_tokens
460
+ FROM (${WhydidDatabase.TOKEN_USAGE_SOURCES}) usage
461
+ WHERE project_id = ?
462
+ GROUP BY model ORDER BY input_tokens DESC`)
463
+ .all(projectId);
464
+ return rows.map((r) => ({ model: r.model, calls: r.calls, inputTokens: r.input_tokens, outputTokens: r.output_tokens }));
465
+ }
466
+ /** Same as getTokenUsageByModel but scoped to created_at >= sinceIso — used for month-to-date budget tracking, where "all-time" would never trip an alert. */
467
+ getTokenUsageByModelSince(projectId, sinceIso) {
468
+ const rows = this.raw
469
+ .prepare(`SELECT COALESCE(model, 'unknown') as model, COUNT(*) as calls,
470
+ SUM(COALESCE(input_tokens, 0)) as input_tokens, SUM(COALESCE(output_tokens, 0)) as output_tokens
471
+ FROM (${WhydidDatabase.TOKEN_USAGE_SOURCES}) usage
472
+ WHERE project_id = ? AND created_at >= ?
473
+ GROUP BY model ORDER BY input_tokens DESC`)
474
+ .all(projectId, sinceIso);
475
+ return rows.map((r) => ({ model: r.model, calls: r.calls, inputTokens: r.input_tokens, outputTokens: r.output_tokens }));
476
+ }
477
+ insertFork(f) {
478
+ this.raw
479
+ .prepare(`INSERT INTO forks (id, project_id, source_state_id, name, created_at) VALUES (?, ?, ?, ?, ?)`)
480
+ .run(f.id, f.projectId, f.sourceStateId, f.name, new Date().toISOString());
481
+ }
482
+ listForks(projectId) {
483
+ const rows = this.raw.prepare(`SELECT * FROM forks WHERE project_id = ?`).all(projectId);
484
+ return rows.map((r) => ({
485
+ id: r.id, projectId: r.project_id, sourceStateId: r.source_state_id, name: r.name, createdAt: r.created_at,
486
+ }));
487
+ }
488
+ /** Idempotent by id — safe to call again after a daemon restart re-scans a session file. */
489
+ insertSession(s) {
490
+ this.raw
491
+ .prepare(`INSERT OR IGNORE INTO sessions (id, project_id, agent, agent_session_id, started_at, ended_at, metadata_json)
492
+ VALUES (?, ?, ?, ?, ?, NULL, ?)`)
493
+ .run(s.id, s.projectId, s.agent, s.agentSessionId, s.startedAt, s.metadataJson ?? null);
494
+ }
495
+ getSessionByAgentSessionId(projectId, agent, agentSessionId) {
496
+ const row = this.raw
497
+ .prepare(`SELECT * FROM sessions WHERE project_id = ? AND agent = ? AND agent_session_id = ?`)
498
+ .get(projectId, agent, agentSessionId);
499
+ return row ? this.rowToSession(row) : undefined;
500
+ }
501
+ getSession(id) {
502
+ const row = this.raw.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
503
+ return row ? this.rowToSession(row) : undefined;
504
+ }
505
+ listSessions(projectId, opts = {}) {
506
+ let sql = `SELECT * FROM sessions WHERE project_id = ? ORDER BY started_at DESC, id DESC`;
507
+ const params = [projectId];
508
+ if (opts.limit != null) {
509
+ sql += ` LIMIT ? OFFSET ?`;
510
+ params.push(opts.limit, opts.offset ?? 0);
511
+ }
512
+ const rows = this.raw.prepare(sql).all(...params);
513
+ return rows.map((r) => this.rowToSession(r));
514
+ }
515
+ countSessions(projectId) {
516
+ const row = this.raw.prepare(`SELECT COUNT(*) as n FROM sessions WHERE project_id = ?`).get(projectId);
517
+ return row.n;
518
+ }
519
+ getMostRecentActiveTurn(projectId) {
520
+ const row = this.raw.prepare(`SELECT s.id AS session_id, s.agent, t.id AS turn_id, t.prompt
521
+ FROM turns t JOIN sessions s ON s.id = t.session_id
522
+ WHERE s.project_id = ? AND t.status = 'in_progress'
523
+ ORDER BY t.started_at DESC, t.id DESC LIMIT 1`).get(projectId);
524
+ return row ? { sessionId: row.session_id, turnId: row.turn_id, prompt: row.prompt, agent: row.agent } : undefined;
525
+ }
526
+ listActiveTurns(projectId) {
527
+ const rows = this.raw.prepare(`SELECT s.id AS session_id, t.id AS turn_id
528
+ FROM turns t JOIN sessions s ON s.id = t.session_id
529
+ WHERE s.project_id = ? AND t.status = 'in_progress'`).all(projectId);
530
+ return rows.map((row) => ({ sessionId: row.session_id, turnId: row.turn_id }));
531
+ }
532
+ updateSessionEndedAt(sessionId, endedAt = new Date().toISOString()) {
533
+ this.raw.prepare(`UPDATE sessions SET ended_at = ? WHERE id = ?`).run(endedAt, sessionId);
534
+ }
535
+ // Ordered by started_at, not sequence: a Claude Code session's sequence
536
+ // numbers come from an in-memory counter (ClaudeCodeTailer) that starts
537
+ // over at 0 every time the daemon restarts mid-session, so two turns
538
+ // discovered across a restart can end up with colliding or out-of-order
539
+ // sequence values even though their real timestamps are always correct.
540
+ // (Codex/Cursor turns, written through hook-events.ts off a fresh
541
+ // getLatestTurn() query every time, don't have this problem — but
542
+ // ordering everyone by the timestamp that's always reliable is simpler
543
+ // than trusting sequence only for the agents where it happens to hold.)
544
+ listTurns(sessionId) {
545
+ const rows = this.raw.prepare(`SELECT * FROM turns WHERE session_id = ? ORDER BY started_at ASC, sequence ASC`).all(sessionId);
546
+ return rows.map((r) => this.rowToTurn(r));
547
+ }
548
+ /** States attributed to a turn (its session/turn id columns), newest first. */
549
+ getStatesForTurn(turnId) {
550
+ const rows = this.raw.prepare(`SELECT * FROM states WHERE turn_id = ? ORDER BY created_at DESC`).all(turnId);
551
+ return rows.map((r) => this.rowToState(r));
552
+ }
553
+ rowToSession(row) {
554
+ return {
555
+ id: row.id, projectId: row.project_id, agent: row.agent, agentSessionId: row.agent_session_id,
556
+ startedAt: row.started_at, endedAt: row.ended_at, metadataJson: row.metadata_json,
557
+ };
558
+ }
559
+ /**
560
+ * Upsert by id (Claude Code's own message uuid) — safe to re-poll the same
561
+ * turn repeatedly as its title/status/touched-files metadata fills in over
562
+ * the course of the turn.
563
+ */
564
+ insertTurn(t) {
565
+ this.raw
566
+ .prepare(`INSERT INTO turns (id, session_id, sequence, prompt, started_at, ended_at, status, metadata_json, kind)
567
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)
568
+ ON CONFLICT(id) DO UPDATE SET prompt = excluded.prompt, status = excluded.status, metadata_json = excluded.metadata_json`)
569
+ .run(t.id, t.sessionId, t.sequence, t.prompt, t.startedAt, t.status, t.metadataJson ?? null, t.kind ?? "prompt");
570
+ }
571
+ updateTurnStatus(turnId, status, endedAt) {
572
+ this.raw.prepare(`UPDATE turns SET status = ?, ended_at = ? WHERE id = ?`).run(status, endedAt ?? new Date().toISOString(), turnId);
573
+ }
574
+ getTurn(id) {
575
+ const row = this.raw.prepare(`SELECT * FROM turns WHERE id = ?`).get(id);
576
+ return row ? this.rowToTurn(row) : undefined;
577
+ }
578
+ getLatestTurn(sessionId) {
579
+ const row = this.raw.prepare(`SELECT * FROM turns WHERE session_id = ? ORDER BY sequence DESC LIMIT 1`).get(sessionId);
580
+ return row ? this.rowToTurn(row) : undefined;
581
+ }
582
+ /** Every turn across the project's sessions where a coding agent's own context got auto-compacted and the session continued — newest first. */
583
+ listCompactResumeTurns(projectId) {
584
+ const rows = this.raw
585
+ .prepare(`SELECT t.* FROM turns t JOIN sessions s ON s.id = t.session_id
586
+ WHERE s.project_id = ? AND t.kind = 'compact_resume'
587
+ ORDER BY t.started_at DESC`)
588
+ .all(projectId);
589
+ return rows.map((r) => this.rowToTurn(r));
590
+ }
591
+ rowToTurn(row) {
592
+ return {
593
+ id: row.id, sessionId: row.session_id, sequence: row.sequence, prompt: row.prompt,
594
+ startedAt: row.started_at, endedAt: row.ended_at, status: row.status, metadataJson: row.metadata_json,
595
+ kind: row.kind ?? "prompt",
596
+ };
597
+ }
598
+ insertEvent(e) {
599
+ this.raw
600
+ .prepare(`INSERT INTO events (id, project_id, type, timestamp, payload_json) VALUES (?, ?, ?, ?, ?)`)
601
+ .run(e.id, e.projectId, e.type, new Date().toISOString(), e.payloadJson);
602
+ }
603
+ insertTestRun(t) {
604
+ this.raw
605
+ .prepare(`INSERT INTO test_runs (id, state_id, command, status, exit_code, duration_ms, output_path, created_at)
606
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
607
+ .run(t.id, t.stateId, t.command, t.status, t.exitCode, t.durationMs, t.outputPath, new Date().toISOString());
608
+ }
609
+ getTestRuns(stateId) {
610
+ const rows = this.raw.prepare(`SELECT * FROM test_runs WHERE state_id = ? ORDER BY created_at DESC`).all(stateId);
611
+ return rows.map((r) => ({
612
+ id: r.id, stateId: r.state_id, command: r.command, status: r.status,
613
+ exitCode: r.exit_code, durationMs: r.duration_ms, outputPath: r.output_path, createdAt: r.created_at,
614
+ }));
615
+ }
616
+ getLatestTestRunForProject(projectId) {
617
+ const row = this.raw
618
+ .prepare(`SELECT test_runs.* FROM test_runs
619
+ JOIN states ON states.id = test_runs.state_id
620
+ WHERE states.project_id = ?
621
+ ORDER BY test_runs.created_at DESC LIMIT 1`)
622
+ .get(projectId);
623
+ if (!row)
624
+ return undefined;
625
+ return {
626
+ id: row.id, stateId: row.state_id, command: row.command, status: row.status,
627
+ exitCode: row.exit_code, durationMs: row.duration_ms, outputPath: row.output_path, createdAt: row.created_at,
628
+ };
629
+ }
630
+ }
631
+ //# sourceMappingURL=database.js.map