whydid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -0
- package/dashboard/dist/android-chrome-192x192.png +0 -0
- package/dashboard/dist/android-chrome-512x512.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/index-DImKqodm.js +69 -0
- package/dashboard/dist/assets/index-mHXgqqkj.css +1 -0
- package/dashboard/dist/favicon-16x16.png +0 -0
- package/dashboard/dist/favicon-32x32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/index.html +24 -0
- package/dashboard/dist/logo-mark.svg +8 -0
- package/dashboard/dist/site.webmanifest +11 -0
- package/dist/adapters/claude-code.js +168 -0
- package/dist/adapters/hook-events.js +86 -0
- package/dist/adapters/sync-claude-code.js +85 -0
- package/dist/cli/ai-budget-warning.js +19 -0
- package/dist/cli/commands/agent-event.js +80 -0
- package/dist/cli/commands/checkpoint.js +36 -0
- package/dist/cli/commands/context.js +27 -0
- package/dist/cli/commands/daemon.js +139 -0
- package/dist/cli/commands/export.js +36 -0
- package/dist/cli/commands/init.js +63 -0
- package/dist/cli/commands/key.js +65 -0
- package/dist/cli/commands/license.js +57 -0
- package/dist/cli/commands/milestone.js +64 -0
- package/dist/cli/commands/prompt.js +75 -0
- package/dist/cli/commands/recap.js +48 -0
- package/dist/cli/commands/restore-fork.js +101 -0
- package/dist/cli/commands/status.js +26 -0
- package/dist/cli/commands/test.js +33 -0
- package/dist/cli/commands/timeline.js +23 -0
- package/dist/cli/commands/why.js +71 -0
- package/dist/cli/context.js +48 -0
- package/dist/cli/index.js +77 -0
- package/dist/cli/license-gate.js +26 -0
- package/dist/cli/preflight.js +28 -0
- package/dist/core/agent-context.js +46 -0
- package/dist/core/agent-hooks.js +68 -0
- package/dist/core/ai-budget.js +27 -0
- package/dist/core/chat-retrieval.js +36 -0
- package/dist/core/context-tree.js +135 -0
- package/dist/core/diff.js +30 -0
- package/dist/core/engine.js +321 -0
- package/dist/core/export.js +98 -0
- package/dist/core/file-history.js +31 -0
- package/dist/core/manifest.js +102 -0
- package/dist/core/recap.js +49 -0
- package/dist/core/test-runner.js +23 -0
- package/dist/daemon/listen.js +26 -0
- package/dist/daemon/pidfile.js +36 -0
- package/dist/daemon/server.js +554 -0
- package/dist/db/database.js +631 -0
- package/dist/db/schema.js +186 -0
- package/dist/git/git.js +22 -0
- package/dist/licensing/config.js +28 -0
- package/dist/licensing/license-client.js +45 -0
- package/dist/licensing/license-manager.js +89 -0
- package/dist/licensing/license-payload.js +12 -0
- package/dist/licensing/license-store.js +96 -0
- package/dist/licensing/machine-id.js +78 -0
- package/dist/licensing/secret-store.js +102 -0
- package/dist/licensing/verify-license.js +20 -0
- package/dist/llm/agent-prompt.js +36 -0
- package/dist/llm/annotate-checkpoint.js +67 -0
- package/dist/llm/annotate.js +11 -0
- package/dist/llm/anthropic-provider.js +97 -0
- package/dist/llm/chat.js +35 -0
- package/dist/llm/community-pricing.js +75 -0
- package/dist/llm/factory.js +17 -0
- package/dist/llm/http-error.js +25 -0
- package/dist/llm/noop-provider.js +19 -0
- package/dist/llm/openai-provider.js +85 -0
- package/dist/llm/pricing.js +55 -0
- package/dist/llm/prompt.js +103 -0
- package/dist/llm/provider.js +2 -0
- package/dist/llm/schema.js +49 -0
- package/dist/llm/social-post.js +49 -0
- package/dist/objects/store.js +31 -0
- package/dist/shared/config.js +45 -0
- package/dist/shared/redaction.js +18 -0
- package/dist/shared/types.js +2 -0
- package/dist/watcher/ignore-rules.js +92 -0
- package/dist/watcher/watcher.js +249 -0
- package/package.json +55 -0
|
@@ -0,0 +1,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,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
|
+
}
|