Skip to content

Commit a555ac2

Browse files
committed
Add Windows support for syncing to public repository
Synced from klaatcode v2.4.4 (2026-08-13). Changes since the last sync: - Add Windows support for syncing to public repository (d08e744c) - Add telemetry functionality and repo fingerprinting for enhanced monitoring (c456dde6) Klaatcode-Source-Commit: d08e744cac1574ed04b7b163091a054b0c737f07
1 parent 6e5a73b commit a555ac2

6 files changed

Lines changed: 492 additions & 0 deletions

File tree

‎package-lock.json‎

Lines changed: 80 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
"bench:claude": "bun bench/compare-claude.ts",
4242
"sync:public": "bash scripts/sync-to-public.sh",
4343
"sync:public:push": "bash scripts/sync-to-public.sh --push",
44+
"sync:public:win": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sync-to-public.ps1",
45+
"sync:public:win:push": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sync-to-public.ps1 -Push",
4446
"pull:public": "bash scripts/pull-from-public.sh",
4547
"release": "bun run build && bun run build:binaries"
4648
},

‎src/api/client.ts‎

21 Bytes
Binary file not shown.

‎src/main.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
3232
import { spawnSync as _openBrowser } from "node:child_process";
3333
import { version as VERSION } from "../package.json";
3434
import { loadProjectRules } from "./agent/system-prompt.js";
35+
import {
36+
configureTelemetry,
37+
emit as emitEvent,
38+
flushTelemetry,
39+
installTelemetryExitHook,
40+
} from "./utils/telemetry.js";
41+
import { sessionStartedPayload } from "./utils/repo-fingerprint.js";
3542
import { costUsd } from "./pricing.js";
3643
import { runAcpServer } from "./acp/agent.js";
3744
import { runHeadlessAgent } from "./agent/headless-agent.js";
@@ -183,8 +190,28 @@ async function boot(opts: { baseUrl?: string; dir?: string; resumeId?: string; n
183190
};
184191

185192
const client = new KlaatAIClient({ apiKey, baseUrl, onAuthExpired });
193+
194+
// ── Layer 1 telemetry ───────────────────────────────────────────────────────
195+
// The token is read lazily, so a refresh mid-session is picked up automatically
196+
// rather than pinning the emitter to a token that expires.
197+
configureTelemetry(() => (client.token ? { baseUrl, token: client.token } : null));
198+
installTelemetryExitHook();
199+
// session.started: the repo fingerprint is free, deterministic, and a far better
200+
// per-project prior than anything extracted from a conversation. Nothing here
201+
// identifies the repo — no path, no name, no remote; the branch is hashed.
202+
const fingerprint = sessionStartedPayload(projectRoot);
203+
if (fingerprint) {
204+
emitEvent({
205+
event_type: "session.started",
206+
project_id: fingerprint.projectId,
207+
payload: fingerprint.payload,
208+
});
209+
}
210+
186211
const sessionResult = await runREPL(app, client, { ...config, baseUrl }, projectRoot, { theme, resumeId: opts.resumeId });
187212

213+
await flushTelemetry();
214+
188215
// REPL finished — clean up
189216
app.quit();
190217
await appDone;

‎src/utils/repo-fingerprint.ts‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* Repo fingerprint — the backbone of the per-project profile vector.
3+
*
4+
* Free, deterministic, and far more reliable than anything an LLM could extract from
5+
* a conversation: "this is an 84k-LOC pnpm monorepo on Next 15 with vitest and CI"
6+
* is a stronger prior about how a user works than any number of remembered facts.
7+
*
8+
* PROJECT ID: deliberately reuses resolveProjectId() rather than deriving its own.
9+
* That formula is already frozen across CLI, Desktop and VS Code and is what the code
10+
* graph and the X-KlaatAI-Project header key on. A second derivation would give the
11+
* same repository two identities, and telemetry could never be joined to the graph —
12+
* silently, and only discoverable much later.
13+
*
14+
* PRIVACY: no paths, no repo name, no remote URL, no file names. The branch name is
15+
* HASHED, not sent: branch names routinely carry ticket ids and feature descriptions.
16+
* Everything here is a count, a boolean, or a value from a closed vocabulary.
17+
*
18+
* COST: bounded. One `git ls-files`, one `git status --porcelain`, one package.json
19+
* read, and at most SAMPLE_FILES stat calls. Computed once per process.
20+
*/
21+
22+
import { spawnSync } from "node:child_process";
23+
import { createHash } from "node:crypto";
24+
import { readFileSync, statSync } from "node:fs";
25+
import { join } from "node:path";
26+
import { resolveProjectId } from "./project-id.js";
27+
28+
/** Enough to extrapolate size without walking a large repo. */
29+
const SAMPLE_FILES = 150;
30+
/** Rough bytes-per-line across source code; loc_estimate is explicitly an estimate. */
31+
const BYTES_PER_LINE = 38;
32+
33+
const LANG_BY_EXT: Record<string, string> = {
34+
ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
35+
js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
36+
py: "python", go: "go", rs: "rust", java: "java", kt: "kotlin",
37+
swift: "swift", rb: "ruby", php: "php", cs: "csharp", sql: "sql",
38+
c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp",
39+
vue: "vue", svelte: "svelte", scala: "scala", dart: "dart", ex: "elixir",
40+
sh: "shell", bash: "shell", lua: "lua",
41+
};
42+
43+
/** Closed vocabulary — package name to reported framework. */
44+
const FRAMEWORK_DEPS: Array<[string, string]> = [
45+
["next", "next"], ["react", "react"], ["vue", "vue"], ["svelte", "svelte"],
46+
["@angular/core", "angular"], ["nuxt", "nuxt"], ["astro", "astro"],
47+
["express", "express"], ["fastify", "fastify"], ["nestjs", "nest"],
48+
["@nestjs/core", "nest"], ["electron", "electron"], ["react-native", "react-native"],
49+
["tailwindcss", "tailwind"], ["prisma", "prisma"], ["drizzle-orm", "drizzle"],
50+
];
51+
52+
const TEST_RUNNER_DEPS = ["vitest", "jest", "mocha", "ava", "playwright", "cypress", "bun:test"];
53+
54+
export interface RepoFingerprint {
55+
languages: string[];
56+
frameworks: string[];
57+
package_manager: string | null;
58+
test_runner: string | null;
59+
has_ci: boolean;
60+
monorepo: boolean;
61+
file_count: number;
62+
loc_estimate: number;
63+
git_branch_hash: string | null;
64+
dirty_working_tree: boolean;
65+
}
66+
67+
function git(cwd: string, args: string[]): string | null {
68+
try {
69+
const r = spawnSync("git", args, {
70+
cwd, encoding: "utf-8", timeout: 5_000, maxBuffer: 32 * 1024 * 1024,
71+
});
72+
return r.status === 0 ? r.stdout : null;
73+
} catch { return null; }
74+
}
75+
76+
function detectPackageManager(root: string, files: Set<string>): string | null {
77+
if (files.has("bun.lock") || files.has("bun.lockb")) return "bun";
78+
if (files.has("pnpm-lock.yaml")) return "pnpm";
79+
if (files.has("yarn.lock")) return "yarn";
80+
if (files.has("package-lock.json")) return "npm";
81+
if (files.has("poetry.lock")) return "poetry";
82+
if (files.has("uv.lock")) return "uv";
83+
if (files.has("requirements.txt")) return "pip";
84+
if (files.has("Cargo.lock")) return "cargo";
85+
if (files.has("go.sum")) return "go";
86+
try { statSync(join(root, "package.json")); return "npm"; } catch { return null; }
87+
}
88+
89+
let _cached: RepoFingerprint | null = null;
90+
91+
export function repoFingerprint(root: string): RepoFingerprint | null {
92+
if (_cached) return _cached;
93+
try {
94+
const listing = git(root, ["ls-files"]);
95+
if (listing === null) return null; // not a git repo — no fingerprint
96+
97+
const paths = listing.split("\n").filter(Boolean);
98+
const topLevel = new Set(paths.filter((p) => !p.includes("/")));
99+
100+
// Language histogram straight off the file list — no extra I/O.
101+
const langCounts = new Map<string, number>();
102+
for (const p of paths) {
103+
const ext = p.slice(p.lastIndexOf(".") + 1).toLowerCase();
104+
const lang = LANG_BY_EXT[ext];
105+
if (lang) langCounts.set(lang, (langCounts.get(lang) ?? 0) + 1);
106+
}
107+
const languages = [...langCounts.entries()]
108+
.sort((a, b) => b[1] - a[1]).slice(0, 6).map(([l]) => l);
109+
110+
// Frameworks and test runner from package.json only — a dependency name is a
111+
// closed-vocabulary lookup, never free text.
112+
const frameworks: string[] = [];
113+
let testRunner: string | null = null;
114+
let workspaces = false;
115+
try {
116+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8")) as {
117+
dependencies?: Record<string, string>;
118+
devDependencies?: Record<string, string>;
119+
workspaces?: unknown;
120+
};
121+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
122+
for (const [dep, label] of FRAMEWORK_DEPS) {
123+
if (deps[dep] && !frameworks.includes(label)) frameworks.push(label);
124+
}
125+
testRunner = TEST_RUNNER_DEPS.find((t) => deps[t]) ?? null;
126+
workspaces = Boolean(pkg.workspaces);
127+
} catch { /* no package.json, or unreadable */ }
128+
129+
if (!testRunner) {
130+
if (paths.some((p) => p.startsWith("tests/") || p.endsWith("_test.go"))) testRunner = "go-test";
131+
else if (topLevel.has("pytest.ini") || topLevel.has("tox.ini")) testRunner = "pytest";
132+
}
133+
134+
// Size: exact file count, sampled byte total extrapolated to lines.
135+
const step = Math.max(1, Math.floor(paths.length / SAMPLE_FILES));
136+
let sampledBytes = 0;
137+
let sampled = 0;
138+
for (let i = 0; i < paths.length; i += step) {
139+
try {
140+
sampledBytes += statSync(join(root, paths[i]!)).size;
141+
sampled++;
142+
} catch { /* deleted since ls-files, or unreadable */ }
143+
}
144+
const avgBytes = sampled > 0 ? sampledBytes / sampled : 0;
145+
const locEstimate = Math.round((avgBytes * paths.length) / BYTES_PER_LINE);
146+
147+
const branch = git(root, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() || null;
148+
const status = git(root, ["status", "--porcelain"]);
149+
150+
_cached = {
151+
languages,
152+
frameworks,
153+
package_manager: detectPackageManager(root, topLevel),
154+
test_runner: testRunner,
155+
has_ci: paths.some((p) => p.startsWith(".github/workflows/"))
156+
|| topLevel.has(".gitlab-ci.yml") || topLevel.has("azure-pipelines.yml"),
157+
monorepo: workspaces
158+
|| topLevel.has("pnpm-workspace.yaml") || topLevel.has("turbo.json")
159+
|| topLevel.has("lerna.json") || topLevel.has("nx.json")
160+
|| paths.filter((p) => p.endsWith("package.json")).length > 1,
161+
file_count: paths.length,
162+
loc_estimate: locEstimate,
163+
// Hashed: branch names carry ticket ids and feature descriptions.
164+
git_branch_hash: branch
165+
? createHash("sha256").update(branch).digest("hex").slice(0, 16)
166+
: null,
167+
dirty_working_tree: Boolean(status && status.trim().length > 0),
168+
};
169+
return _cached;
170+
} catch {
171+
return null;
172+
}
173+
}
174+
175+
/** session.started payload, or null outside a git repo. */
176+
export function sessionStartedPayload(root: string): {
177+
projectId: string | null;
178+
payload: Record<string, unknown>;
179+
} | null {
180+
const fp = repoFingerprint(root);
181+
if (!fp) return null;
182+
let projectId: string | null = null;
183+
try { projectId = resolveProjectId(root)?.id ?? null; } catch { /* ignore */ }
184+
return { projectId, payload: { project_fingerprint: fp } };
185+
}
186+
187+
/** Test seam. */
188+
export function _resetFingerprintCache(): void { _cached = null; }

0 commit comments

Comments
 (0)