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,49 @@
1
+ import { z } from "zod";
2
+ export const AnnotationJsonSchema = z.object({
3
+ summary: z.string(),
4
+ reason: z.string(),
5
+ decisions: z.array(z.string()).default([]),
6
+ risk_level: z.enum(["low", "medium", "high"]),
7
+ });
8
+ function extractJsonObject(text) {
9
+ const jsonStart = text.indexOf("{");
10
+ const jsonEnd = text.lastIndexOf("}");
11
+ if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart)
12
+ return null;
13
+ try {
14
+ return JSON.parse(text.slice(jsonStart, jsonEnd + 1));
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function parseAnnotationJson(text) {
21
+ const parsed = extractJsonObject(text);
22
+ if (parsed === null)
23
+ return null;
24
+ const result = AnnotationJsonSchema.safeParse(parsed);
25
+ return result.success ? result.data : null;
26
+ }
27
+ export const SocialPostJsonSchema = z.object({
28
+ worth_sharing: z.boolean(),
29
+ content: z.string(),
30
+ });
31
+ export function parseSocialPostJson(text) {
32
+ const parsed = extractJsonObject(text);
33
+ if (parsed === null)
34
+ return null;
35
+ const result = SocialPostJsonSchema.safeParse(parsed);
36
+ return result.success ? result.data : null;
37
+ }
38
+ export const ChatAnswerJsonSchema = z.object({
39
+ answer: z.string(),
40
+ cited_state_ids: z.array(z.string()).default([]),
41
+ });
42
+ export function parseChatAnswerJson(text) {
43
+ const parsed = extractJsonObject(text);
44
+ if (parsed === null)
45
+ return null;
46
+ const result = ChatAnswerJsonSchema.safeParse(parsed);
47
+ return result.success ? result.data : null;
48
+ }
49
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,49 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * Generates a social post draft from a session's real prompts and
4
+ * whydid's own checkpoint annotations, and persists it if there's
5
+ * anything worth saving.
6
+ *
7
+ * Manual requests save whenever the model actually produced content —
8
+ * the user explicitly picked this session, so their judgment about
9
+ * whether it's worth a post stands even if the model was lukewarm.
10
+ * Automatic requests only save when the model itself judged the session
11
+ * worth_sharing — the whole point of the gate is to keep the draft
12
+ * library free of noise from trivial or in-progress sessions when nobody
13
+ * asked for a draft in the first place. In practice both end up
14
+ * equivalent in the common case, since the prompt instructs the model to
15
+ * leave content empty exactly when it judges a session not worth
16
+ * sharing — but a manual request never overrides real content the model
17
+ * did produce.
18
+ */
19
+ export async function generateAndSaveSocialPost(ctx, provider, projectId, sessionId, platform, source, retentionDays) {
20
+ const turns = ctx.db.listTurns(sessionId);
21
+ const sessionPrompts = turns.map((t) => t.prompt).filter((p) => !!p);
22
+ const checkpointSummaries = [];
23
+ const seenStateIds = new Set();
24
+ for (const turn of turns) {
25
+ for (const state of ctx.db.getStatesForTurn(turn.id)) {
26
+ if (seenStateIds.has(state.id))
27
+ continue;
28
+ seenStateIds.add(state.id);
29
+ const annotation = ctx.db.getAnnotation(state.id);
30
+ if (annotation?.status === "generated" && annotation.summary && annotation.reason) {
31
+ checkpointSummaries.push({ summary: annotation.summary, reason: annotation.reason, decisions: annotation.decisions, riskLevel: annotation.riskLevel });
32
+ }
33
+ }
34
+ }
35
+ const result = await provider.generateSocialPost({ platform, sessionPrompts, checkpointSummaries });
36
+ const shouldSave = result.content.trim().length > 0 && (source === "manual" || result.worthSharing);
37
+ if (!shouldSave) {
38
+ return { saved: false, post: null, worthSharing: result.worthSharing };
39
+ }
40
+ const id = randomUUID();
41
+ ctx.db.insertSocialPost({
42
+ id, projectId, sessionId, platform, content: result.content, source,
43
+ model: result.model ?? null, inputTokens: result.usage?.inputTokens ?? null, outputTokens: result.usage?.outputTokens ?? null,
44
+ });
45
+ ctx.db.pruneOldSocialPosts(projectId, retentionDays);
46
+ const post = ctx.db.getSocialPost(id);
47
+ return { saved: true, post: post ?? null, worthSharing: result.worthSharing };
48
+ }
49
+ //# sourceMappingURL=social-post.js.map
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export function hashContent(content) {
5
+ return createHash("sha256").update(content).digest("hex");
6
+ }
7
+ export class ObjectStore {
8
+ objectsDir;
9
+ constructor(objectsDir) {
10
+ this.objectsDir = objectsDir;
11
+ }
12
+ pathFor(hash) {
13
+ return join(this.objectsDir, hash.slice(0, 2), hash.slice(2));
14
+ }
15
+ write(content) {
16
+ const hash = hashContent(content);
17
+ const path = this.pathFor(hash);
18
+ if (!existsSync(path)) {
19
+ mkdirSync(join(this.objectsDir, hash.slice(0, 2)), { recursive: true });
20
+ writeFileSync(path, content);
21
+ }
22
+ return hash;
23
+ }
24
+ read(hash) {
25
+ return readFileSync(this.pathFor(hash));
26
+ }
27
+ has(hash) {
28
+ return existsSync(this.pathFor(hash));
29
+ }
30
+ }
31
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,45 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const DEFAULT_CONFIG = {
4
+ projectName: "",
5
+ ignoredPaths: ["node_modules", ".next", "dist", "build", ".venv"],
6
+ checkpoint: {
7
+ debounceMs: 2500,
8
+ minIntervalMs: 10000,
9
+ minChangedFiles: 1,
10
+ minChangedBytes: 1,
11
+ },
12
+ ai: { provider: "noop", model: "" },
13
+ test: null,
14
+ privacy: { sendPrompts: true, sendDiffs: true },
15
+ contextTree: { enabled: true },
16
+ buildInPublic: { enabled: false, retentionDays: 14 },
17
+ aiBudget: { monthlyLimitUsd: null, alertThresholdPercent: 80 },
18
+ pricingOverrides: {},
19
+ };
20
+ function configPath(whydidDir) {
21
+ return join(whydidDir, "config.json");
22
+ }
23
+ export function loadConfig(whydidDir) {
24
+ const path = configPath(whydidDir);
25
+ if (!existsSync(path)) {
26
+ return structuredClone(DEFAULT_CONFIG);
27
+ }
28
+ const onDisk = JSON.parse(readFileSync(path, "utf-8"));
29
+ return {
30
+ ...structuredClone(DEFAULT_CONFIG),
31
+ ...onDisk,
32
+ checkpoint: { ...DEFAULT_CONFIG.checkpoint, ...(onDisk.checkpoint ?? {}) },
33
+ ai: { ...DEFAULT_CONFIG.ai, ...(onDisk.ai ?? {}) },
34
+ test: onDisk.test ?? DEFAULT_CONFIG.test,
35
+ privacy: { ...DEFAULT_CONFIG.privacy, ...(onDisk.privacy ?? {}) },
36
+ contextTree: { ...DEFAULT_CONFIG.contextTree, ...(onDisk.contextTree ?? {}) },
37
+ buildInPublic: { ...DEFAULT_CONFIG.buildInPublic, ...(onDisk.buildInPublic ?? {}) },
38
+ aiBudget: { ...DEFAULT_CONFIG.aiBudget, ...(onDisk.aiBudget ?? {}) },
39
+ pricingOverrides: { ...DEFAULT_CONFIG.pricingOverrides, ...(onDisk.pricingOverrides ?? {}) },
40
+ };
41
+ }
42
+ export function saveConfig(whydidDir, config) {
43
+ writeFileSync(configPath(whydidDir), JSON.stringify(config, null, 2) + "\n", "utf-8");
44
+ }
45
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,18 @@
1
+ const PATTERNS = [
2
+ /sk_live_[A-Za-z0-9]{16,}/g,
3
+ /sk_test_[A-Za-z0-9]{16,}/g,
4
+ /AKIA[0-9A-Z]{16}/g,
5
+ /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
6
+ /(postgres|postgresql|mysql|mongodb(\+srv)?):\/\/[^\s:]+:[^\s@]+@[^\s]+/g,
7
+ /(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
8
+ ];
9
+ const KEY_LIKE_ASSIGNMENT = /\b([A-Z_][A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD)[A-Z0-9_]*)\s*=\s*(\S+)/g;
10
+ export function redactSecrets(text) {
11
+ let out = text;
12
+ for (const pattern of PATTERNS) {
13
+ out = out.replace(pattern, "[REDACTED_SECRET]");
14
+ }
15
+ out = out.replace(KEY_LIKE_ASSIGNMENT, (_match, name) => `${name}=[REDACTED_SECRET]`);
16
+ return out;
17
+ }
18
+ //# sourceMappingURL=redaction.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { createRequire } from "node:module";
4
+ const nodeRequire = createRequire(import.meta.url);
5
+ const ignoreFactory = nodeRequire("ignore");
6
+ const BUILT_IN_EXCLUDES = [
7
+ ".git", ".whydid", "node_modules", ".next", "dist", "build", ".venv",
8
+ ];
9
+ /**
10
+ * Rewrites one line from a nested .gitignore (e.g. `landing/.gitignore`)
11
+ * into the equivalent pattern rooted at the project root, matching git's
12
+ * own semantics: a pattern containing a `/` (anchored, whether written
13
+ * with a leading slash or not — git treats any internal slash as anchoring
14
+ * to that .gitignore's own directory) is prefixed directly; a pattern with
15
+ * no `/` at all matches at any depth under that directory, so it needs a
16
+ * `**\/` in between. Comments and blank lines are dropped; negation (`!`)
17
+ * is preserved.
18
+ */
19
+ function rebasePattern(line, relativeDir) {
20
+ const trimmed = line.trim();
21
+ if (trimmed === "" || trimmed.startsWith("#"))
22
+ return null;
23
+ const negated = trimmed.startsWith("!");
24
+ const body = negated ? trimmed.slice(1) : trimmed;
25
+ const anchored = body.startsWith("/");
26
+ const core = anchored ? body.slice(1) : body;
27
+ const hasInternalSlash = core.includes("/");
28
+ const rebased = anchored || hasInternalSlash ? `${relativeDir}/${core}` : `${relativeDir}/**/${core}`;
29
+ return negated ? `!${rebased}` : rebased;
30
+ }
31
+ /**
32
+ * Walks the project tree looking for .gitignore files below the root one
33
+ * (already handled separately), skipping any directory the rules built up
34
+ * so far already exclude — so this never descends into node_modules, the
35
+ * object store, etc. Each nested file's patterns are rebased onto the
36
+ * project root before being added, so they only ever apply within their
37
+ * own subtree, exactly like git itself resolves them.
38
+ */
39
+ function collectNestedGitignorePatterns(projectRoot, ig) {
40
+ const patterns = [];
41
+ function walk(absDir) {
42
+ let entries;
43
+ try {
44
+ entries = readdirSync(absDir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ for (const entry of entries) {
50
+ if (!entry.isDirectory())
51
+ continue;
52
+ const absChild = join(absDir, entry.name);
53
+ const relChild = relative(projectRoot, absChild).split("\\").join("/");
54
+ if (ig.ignores(relChild))
55
+ continue;
56
+ const gitignorePath = join(absChild, ".gitignore");
57
+ if (existsSync(gitignorePath)) {
58
+ const lines = readFileSync(gitignorePath, "utf-8").split("\n");
59
+ for (const line of lines) {
60
+ const rebased = rebasePattern(line, relChild);
61
+ if (rebased) {
62
+ patterns.push(rebased);
63
+ ig.add(rebased);
64
+ }
65
+ }
66
+ }
67
+ walk(absChild);
68
+ }
69
+ }
70
+ walk(projectRoot);
71
+ return patterns;
72
+ }
73
+ export class IgnoreRules {
74
+ ig;
75
+ constructor(projectRoot, extraIgnoredPaths) {
76
+ this.ig = ignoreFactory();
77
+ this.ig.add(BUILT_IN_EXCLUDES);
78
+ this.ig.add(extraIgnoredPaths);
79
+ const gitignorePath = join(projectRoot, ".gitignore");
80
+ if (existsSync(gitignorePath)) {
81
+ this.ig.add(readFileSync(gitignorePath, "utf-8"));
82
+ }
83
+ collectNestedGitignorePatterns(projectRoot, this.ig);
84
+ }
85
+ isIgnored(relativePath) {
86
+ const normalized = relativePath.split("\\").join("/").replace(/^\/+/, "");
87
+ if (!normalized)
88
+ return false;
89
+ return this.ig.ignores(normalized);
90
+ }
91
+ }
92
+ //# sourceMappingURL=ignore-rules.js.map
@@ -0,0 +1,249 @@
1
+ import chokidar from "chokidar";
2
+ import { createCheckpoint } from "../core/engine.js";
3
+ import { annotateCheckpoint } from "../llm/annotate-checkpoint.js";
4
+ import { updateContextTree } from "../core/context-tree.js";
5
+ import { generateAndSaveSocialPost } from "../llm/social-post.js";
6
+ import { NoopProvider } from "../llm/noop-provider.js";
7
+ import { syncClaudeCodeTurns } from "../adapters/sync-claude-code.js";
8
+ const TURN_POLL_MS = 250;
9
+ export class ProjectWatcher {
10
+ ctx;
11
+ projectId;
12
+ config;
13
+ claudeProjectsDir;
14
+ turnPollMs;
15
+ privacy;
16
+ contextTreeEnabled;
17
+ buildInPublicEnabled;
18
+ buildInPublicRetentionDays;
19
+ fsWatcher = null;
20
+ debounceTimer = null;
21
+ turnPollTimer = null;
22
+ lastCheckpointAt = 0;
23
+ pollingMode = false;
24
+ callbacks = [];
25
+ llmProvider;
26
+ isReady = false;
27
+ running = false;
28
+ pendingForcedCheckpoints = [];
29
+ lastFinalizedTurnId = null;
30
+ lastBuildInPublicSessionId = null;
31
+ // Latest active coding-agent session/turn, used to attribute checkpoints.
32
+ currentSessionId = null;
33
+ currentTurnId = null;
34
+ currentTurnPrompt = null;
35
+ constructor(ctx, projectId, config, llmProvider, claudeProjectsDir, turnPollMs = TURN_POLL_MS, privacy = { sendPrompts: true, sendDiffs: true }, contextTreeEnabled = true, buildInPublicEnabled = false, buildInPublicRetentionDays = 14) {
36
+ this.ctx = ctx;
37
+ this.projectId = projectId;
38
+ this.config = config;
39
+ this.claudeProjectsDir = claudeProjectsDir;
40
+ this.turnPollMs = turnPollMs;
41
+ this.privacy = privacy;
42
+ this.contextTreeEnabled = contextTreeEnabled;
43
+ this.buildInPublicEnabled = buildInPublicEnabled;
44
+ this.buildInPublicRetentionDays = buildInPublicRetentionDays;
45
+ this.llmProvider = llmProvider ?? new NoopProvider();
46
+ }
47
+ onCheckpoint(cb) {
48
+ this.callbacks.push(cb);
49
+ }
50
+ // Lets a running daemon pick up an AI provider change made in Settings
51
+ // without requiring a restart. (A change to *which key is present in the
52
+ // environment* still needs a restart — the daemon process's env is fixed
53
+ // at spawn time — but switching between already-available providers, or
54
+ // toggling privacy settings that flow through config, takes effect on the
55
+ // very next checkpoint.)
56
+ setLLMProvider(provider) {
57
+ this.llmProvider = provider;
58
+ }
59
+ setPrivacy(privacy) {
60
+ this.privacy = privacy;
61
+ }
62
+ setContextTreeEnabled(enabled) {
63
+ this.contextTreeEnabled = enabled;
64
+ }
65
+ setBuildInPublic(config) {
66
+ this.buildInPublicEnabled = config.enabled;
67
+ this.buildInPublicRetentionDays = config.retentionDays;
68
+ }
69
+ start() {
70
+ this.startFsWatcher(false);
71
+ this.pollAgentTurn();
72
+ this.turnPollTimer = setInterval(() => this.pollAgentTurn(), this.turnPollMs);
73
+ }
74
+ // Native fs watching opens one OS handle per watched file on some
75
+ // platforms (confirmed via chokidar's _watchWithNodeFs path). On a large
76
+ // enough tree (verified: a 50,000-file synthetic project) this exceeds the
77
+ // process's file-descriptor limit and chokidar emits an EMFILE error —
78
+ // which, being an EventEmitter 'error' event with no listener attached,
79
+ // previously crashed the entire daemon process outright (confirmed: the
80
+ // process died while its pidfile kept claiming it was running). Watching
81
+ // now always has an error handler, and on EMFILE specifically it retries
82
+ // once in polling mode, which has a fixed CPU cost instead of one handle
83
+ // per file and so isn't subject to the same ceiling. Any other watch
84
+ // error is logged and the daemon keeps running rather than crashing —
85
+ // the agent turn-boundary checkpoint path is independent
86
+ // of file watching and keeps working as a safety net either way, and
87
+ // `whydid checkpoint` always works as a manual fallback.
88
+ startFsWatcher(usePolling) {
89
+ this.isReady = false;
90
+ this.pollingMode = usePolling;
91
+ const watcher = chokidar.watch(this.ctx.projectRoot, {
92
+ ignored: (path) => {
93
+ const rel = path.replace(this.ctx.projectRoot, "").replace(/^[\\/]/, "");
94
+ return rel !== "" && this.ctx.ignoreRules.isIgnored(rel);
95
+ },
96
+ ignoreInitial: true,
97
+ persistent: true,
98
+ usePolling,
99
+ });
100
+ this.fsWatcher = watcher;
101
+ watcher.on("ready", () => {
102
+ this.isReady = true;
103
+ });
104
+ watcher.on("all", () => this.scheduleCheckpoint());
105
+ watcher.on("error", (err) => {
106
+ const code = err?.code;
107
+ if (code === "EMFILE" && !usePolling) {
108
+ console.error("[whydid] File watching hit the OS file-descriptor limit on this project's size — falling back to polling mode.");
109
+ void watcher.close().finally(() => this.startFsWatcher(true));
110
+ return;
111
+ }
112
+ console.error("[whydid] File watcher error (agent turn-boundary checkpoints and `whydid checkpoint` still work):", err);
113
+ });
114
+ }
115
+ /**
116
+ * Polls Claude Code's local transcript and the database's hook-reported
117
+ * active turn. At a prompt boundary, pending changes are checkpointed
118
+ * against the turn that just ended instead of waiting for debounce.
119
+ */
120
+ pollAgentTurn() {
121
+ let claudeTurn = null;
122
+ try {
123
+ claudeTurn = syncClaudeCodeTurns(this.ctx, this.projectId, this.claudeProjectsDir);
124
+ }
125
+ catch { }
126
+ const activeTurn = this.ctx.db.getMostRecentActiveTurn(this.projectId);
127
+ if (activeTurn && this.currentTurnId && activeTurn.turnId !== this.currentTurnId) {
128
+ if (this.debounceTimer) {
129
+ clearTimeout(this.debounceTimer);
130
+ this.debounceTimer = null;
131
+ }
132
+ this.queueForcedCheckpoint(this.currentSessionId ?? activeTurn.sessionId, this.currentTurnId);
133
+ }
134
+ if (!activeTurn) {
135
+ if (this.currentSessionId && this.currentTurnId) {
136
+ const turn = this.ctx.db.getTurn(this.currentTurnId);
137
+ if (turn?.status === "completed") {
138
+ if (this.debounceTimer) {
139
+ clearTimeout(this.debounceTimer);
140
+ this.debounceTimer = null;
141
+ }
142
+ this.queueForcedCheckpoint(this.currentSessionId, this.currentTurnId);
143
+ this.currentTurnId = null;
144
+ this.currentTurnPrompt = null;
145
+ }
146
+ }
147
+ this.maybeDraftForEndedSession(this.currentSessionId);
148
+ return;
149
+ }
150
+ // Two separate completion signals, not redundant: Codex/Cursor sessions
151
+ // get an explicit SessionEnd hook event, recorded as sessions.ended_at
152
+ // (checked inside maybeDraftForEndedSession) — but Claude Code's local
153
+ // transcript has no such signal, only turns, so its session-end is
154
+ // inferred here from "a new session file appeared" instead. Both funnel
155
+ // through the same lastBuildInPublicSessionId guard, so neither can
156
+ // double-fire for the same session.
157
+ if (this.currentSessionId && activeTurn.sessionId !== this.currentSessionId) {
158
+ this.maybeDraftForEndedSession(this.currentSessionId);
159
+ }
160
+ if (this.buildInPublicEnabled && claudeTurn && this.currentSessionId && claudeTurn.sessionId !== this.currentSessionId) {
161
+ const endedSessionId = this.currentSessionId;
162
+ if (this.lastBuildInPublicSessionId !== endedSessionId) {
163
+ this.lastBuildInPublicSessionId = endedSessionId;
164
+ generateAndSaveSocialPost(this.ctx, this.llmProvider, this.projectId, endedSessionId, "x", "automatic", this.buildInPublicRetentionDays).catch(() => { });
165
+ }
166
+ }
167
+ this.currentSessionId = activeTurn.sessionId;
168
+ this.currentTurnId = activeTurn.turnId;
169
+ this.currentTurnPrompt = activeTurn.prompt;
170
+ }
171
+ queueForcedCheckpoint(sessionId, turnId) {
172
+ if (this.lastFinalizedTurnId === turnId)
173
+ return;
174
+ this.lastFinalizedTurnId = turnId;
175
+ this.pendingForcedCheckpoints.push({ sessionId, turnId });
176
+ if (!this.running) {
177
+ const pending = this.pendingForcedCheckpoints.shift();
178
+ void this.maybeCheckpoint({ force: true, ...pending });
179
+ }
180
+ }
181
+ maybeDraftForEndedSession(sessionId) {
182
+ if (!this.buildInPublicEnabled || !sessionId || this.lastBuildInPublicSessionId === sessionId)
183
+ return;
184
+ const session = this.ctx.db.getSession(sessionId);
185
+ if (!session?.endedAt)
186
+ return;
187
+ this.lastBuildInPublicSessionId = sessionId;
188
+ generateAndSaveSocialPost(this.ctx, this.llmProvider, this.projectId, sessionId, "x", "automatic", this.buildInPublicRetentionDays).catch(() => { });
189
+ }
190
+ scheduleCheckpoint() {
191
+ if (this.debounceTimer)
192
+ clearTimeout(this.debounceTimer);
193
+ this.debounceTimer = setTimeout(() => void this.maybeCheckpoint(), this.config.debounceMs);
194
+ }
195
+ async maybeCheckpoint(opts = {}) {
196
+ if (this.running) {
197
+ if (opts.force && opts.sessionId && opts.turnId && !this.pendingForcedCheckpoints.some((item) => item.turnId === opts.turnId)) {
198
+ this.pendingForcedCheckpoints.push({ sessionId: opts.sessionId, turnId: opts.turnId });
199
+ }
200
+ return;
201
+ }
202
+ if (opts.force && opts.turnId) {
203
+ const queuedIndex = this.pendingForcedCheckpoints.findIndex((item) => item.turnId === opts.turnId);
204
+ if (queuedIndex !== -1)
205
+ this.pendingForcedCheckpoints.splice(queuedIndex, 1);
206
+ }
207
+ if (!opts.force) {
208
+ const elapsed = Date.now() - this.lastCheckpointAt;
209
+ if (this.lastCheckpointAt !== 0 && elapsed < this.config.minIntervalMs)
210
+ return;
211
+ }
212
+ this.running = true;
213
+ let result;
214
+ try {
215
+ const sessionId = opts.sessionId ?? this.currentSessionId ?? undefined;
216
+ const turnId = opts.turnId ?? this.currentTurnId ?? undefined;
217
+ result = await createCheckpoint(this.ctx, { projectId: this.projectId, sessionId, turnId });
218
+ }
219
+ finally {
220
+ this.running = false;
221
+ const pending = this.pendingForcedCheckpoints.shift();
222
+ if (pending)
223
+ void this.maybeCheckpoint({ force: true, ...pending });
224
+ }
225
+ if (result.changed) {
226
+ this.lastCheckpointAt = Date.now();
227
+ const userInstruction = opts.turnId ? undefined : this.currentTurnPrompt ?? undefined;
228
+ const annotation = await annotateCheckpoint(this.ctx, this.llmProvider, result.stateId, result.fileChanges, {
229
+ userInstruction,
230
+ sendPrompts: this.privacy.sendPrompts,
231
+ sendDiffs: this.privacy.sendDiffs,
232
+ });
233
+ if (annotation.status === "generated" && this.contextTreeEnabled) {
234
+ await updateContextTree(this.ctx, this.projectId, result.fileChanges.map((c) => c.filePath));
235
+ }
236
+ }
237
+ for (const cb of this.callbacks)
238
+ cb({ stateId: result.stateId, changed: result.changed });
239
+ }
240
+ async stop() {
241
+ if (this.debounceTimer)
242
+ clearTimeout(this.debounceTimer);
243
+ if (this.turnPollTimer)
244
+ clearInterval(this.turnPollTimer);
245
+ await this.fsWatcher?.close();
246
+ this.fsWatcher = null;
247
+ }
248
+ }
249
+ //# sourceMappingURL=watcher.js.map
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "whydid",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "license": "UNLICENSED",
6
+ "description": "A local-first AI coding time machine — captures meaningful changes, reasoning, and session context so you can see what happened across your coding sessions, even days later.",
7
+ "homepage": "https://whydid.dev",
8
+ "keywords": [
9
+ "ai",
10
+ "coding-agent",
11
+ "checkpoint",
12
+ "cli",
13
+ "developer-tools",
14
+ "local-first"
15
+ ],
16
+ "bin": {
17
+ "whydid": "dist/cli/index.js"
18
+ },
19
+ "files": [
20
+ "dist/**/*.js",
21
+ "dashboard/dist/**",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "typecheck": "tsc --noEmit",
29
+ "dashboard:dev": "vite --config dashboard/vite.config.ts",
30
+ "dashboard:build": "vite build --config dashboard/vite.config.ts",
31
+ "dashboard:test": "npm --prefix dashboard run test",
32
+ "dashboard:build:demo": "npm --prefix dashboard run build:demo",
33
+ "landing:build": "npm --prefix landing run build",
34
+ "landing:test": "npm --prefix landing run test"
35
+ },
36
+ "engines": {
37
+ "node": ">=24"
38
+ },
39
+ "dependencies": {
40
+ "@napi-rs/keyring": "^2.1.0",
41
+ "chokidar": "^4.0.3",
42
+ "commander": "^12.1.0",
43
+ "diff": "^7.0.0",
44
+ "express": "^4.21.1",
45
+ "ignore": "^6.0.2",
46
+ "zod": "^3.23.8"
47
+ },
48
+ "devDependencies": {
49
+ "@types/diff": "^7.0.2",
50
+ "@types/express": "^4.17.21",
51
+ "@types/node": "^24.0.0",
52
+ "typescript": "^5.6.3",
53
+ "vitest": "^2.1.4"
54
+ }
55
+ }