@zewstid/zeye-capture 0.2.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.
@@ -0,0 +1,289 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.localHostConfigPath = localHostConfigPath;
4
+ exports.localOutboxDirectory = localOutboxDirectory;
5
+ exports.ensurePrivateDirectory = ensurePrivateDirectory;
6
+ exports.readLocalHostConfig = readLocalHostConfig;
7
+ exports.writeLocalHostConfig = writeLocalHostConfig;
8
+ exports.discoverRepositoryIdentity = discoverRepositoryIdentity;
9
+ exports.captureRepositoryReference = captureRepositoryReference;
10
+ exports.captureLockStatus = captureLockStatus;
11
+ exports.acquireCaptureLock = acquireCaptureLock;
12
+ exports.withCaptureLock = withCaptureLock;
13
+ exports.installClaudeHooks = installClaudeHooks;
14
+ exports.uninstallClaudeHooks = uninstallClaudeHooks;
15
+ exports.installCodexSource = installCodexSource;
16
+ exports.uninstallCodexSource = uninstallCodexSource;
17
+ exports.configuredSourcesForRepository = configuredSourcesForRepository;
18
+ const promises_1 = require("node:fs/promises");
19
+ const node_child_process_1 = require("node:child_process");
20
+ const node_os_1 = require("node:os");
21
+ const node_path_1 = require("node:path");
22
+ const node_util_1 = require("node:util");
23
+ const canonical_1 = require("./canonical");
24
+ const execFile = (0, node_util_1.promisify)(node_child_process_1.execFile);
25
+ const ZEYE_HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop', 'PreCompact'];
26
+ const EMPTY_CONFIG = { version: 1, claudeInstalls: [], codexSources: [] };
27
+ function localHostConfigPath(environment = process.env) {
28
+ const base = environment.XDG_CONFIG_HOME || (0, node_path_1.join)(environment.HOME || (0, node_os_1.homedir)(), '.config');
29
+ return (0, node_path_1.join)(base, 'zeye-capture', 'host-v1.json');
30
+ }
31
+ function localOutboxDirectory(repositoryRoot, environment = process.env) {
32
+ const repositoryScope = (0, canonical_1.sha256)({ repositoryRoot: (0, node_path_1.resolve)(repositoryRoot) }).slice(0, 32);
33
+ // An override is a private *base* directory, not a shared outbox. Keeping a
34
+ // per-repository scope prevents events from separate checkouts being
35
+ // replayed under whichever repository is current when sync runs.
36
+ if (environment.ZEYE_CAPTURE_OUTBOX_DIR?.trim())
37
+ return (0, node_path_1.join)((0, node_path_1.resolve)(environment.ZEYE_CAPTURE_OUTBOX_DIR), repositoryScope);
38
+ const base = environment.XDG_STATE_HOME || (0, node_path_1.join)(environment.HOME || (0, node_os_1.homedir)(), '.local', 'state');
39
+ return (0, node_path_1.join)(base, 'zeye-capture', repositoryScope);
40
+ }
41
+ async function ensurePrivateDirectory(directory) {
42
+ await (0, promises_1.mkdir)(directory, { recursive: true, mode: 0o700 });
43
+ // chmod is meaningful on POSIX and harmless on platforms with a different
44
+ // ACL model. We never weaken an existing directory's mode.
45
+ await (0, promises_1.chmod)(directory, 0o700).catch(() => undefined);
46
+ }
47
+ async function writePrivateJson(path, value) {
48
+ await ensurePrivateDirectory((0, node_path_1.dirname)(path));
49
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
50
+ await (0, promises_1.writeFile)(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
51
+ await (0, promises_1.rename)(temporary, path);
52
+ await (0, promises_1.chmod)(path, 0o600).catch(() => undefined);
53
+ }
54
+ async function readJsonObject(path) {
55
+ try {
56
+ const raw = await (0, promises_1.readFile)(path, 'utf8');
57
+ const value = JSON.parse(raw);
58
+ if (!value || typeof value !== 'object' || Array.isArray(value))
59
+ throw new Error('expected an object');
60
+ return value;
61
+ }
62
+ catch (error) {
63
+ if (error.code === 'ENOENT')
64
+ return {};
65
+ throw new Error(`Cannot safely read JSON configuration at ${path}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
66
+ }
67
+ }
68
+ async function readLocalHostConfig(path = localHostConfigPath()) {
69
+ const value = await readJsonObject(path);
70
+ if (Object.keys(value).length === 0)
71
+ return structuredClone(EMPTY_CONFIG);
72
+ if (value.version !== 1 || !Array.isArray(value.claudeInstalls) || !Array.isArray(value.codexSources))
73
+ throw new Error(`Unsupported local host configuration at ${path}`);
74
+ const claudeInstalls = value.claudeInstalls.filter((entry) => Boolean(entry && typeof entry === 'object' && typeof entry.repositoryRoot === 'string' && typeof entry.settingsPath === 'string' && typeof entry.command === 'string'));
75
+ const codexSources = value.codexSources.filter((entry) => Boolean(entry && typeof entry === 'object' && typeof entry.repositoryRoot === 'string' && typeof entry.jsonlPath === 'string'));
76
+ return { version: 1, claudeInstalls, codexSources };
77
+ }
78
+ async function writeLocalHostConfig(config, path = localHostConfigPath()) {
79
+ await writePrivateJson(path, config);
80
+ }
81
+ async function git(repositoryPath, args) {
82
+ try {
83
+ const result = await execFile('git', ['-C', repositoryPath, ...args], { maxBuffer: 64 * 1024 });
84
+ return result.stdout.trim() || null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function parseRemoteFullName(remote) {
91
+ if (!remote)
92
+ return undefined;
93
+ const path = /^([^@]+@)?[^:]+:(.+)$/.exec(remote)?.[2]
94
+ || /^(?:https?|ssh):\/\/(?:[^@/]+@)?[^/]+\/(.+)$/.exec(remote)?.[1];
95
+ const fullName = path?.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, '');
96
+ return fullName && /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/.test(fullName) ? fullName : undefined;
97
+ }
98
+ async function discoverRepositoryIdentity(repositoryPath, options = {}) {
99
+ const repositoryRoot = await git(repositoryPath, ['rev-parse', '--show-toplevel']);
100
+ if (!repositoryRoot)
101
+ return null;
102
+ const [remote, branch, headCommitSha] = await Promise.all([
103
+ git(repositoryRoot, ['config', '--get', 'remote.origin.url']),
104
+ git(repositoryRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
105
+ git(repositoryRoot, ['rev-parse', 'HEAD']),
106
+ ]);
107
+ return {
108
+ repositoryRoot,
109
+ fullName: parseRemoteFullName(remote),
110
+ providerRepositoryId: options.providerRepositoryId?.trim() || undefined,
111
+ branch: branch || undefined,
112
+ headCommitSha: headCommitSha && /^[a-f0-9]{7,64}$/i.test(headCommitSha) ? headCommitSha : undefined,
113
+ remoteUrlHash: remote ? (0, canonical_1.sha256)(remote) : undefined,
114
+ };
115
+ }
116
+ function captureRepositoryReference(identity, repositoryId) {
117
+ if (!identity)
118
+ return [];
119
+ const reference = {
120
+ ...(repositoryId?.trim() ? { repositoryId: repositoryId.trim() } : {}),
121
+ ...(identity.providerRepositoryId ? { providerRepositoryId: identity.providerRepositoryId } : {}),
122
+ ...(identity.fullName ? { canonicalName: identity.fullName } : {}),
123
+ ...(identity.remoteUrlHash ? { remoteUrlHash: identity.remoteUrlHash } : {}),
124
+ ...(identity.branch ? { branch: identity.branch } : {}),
125
+ ...(identity.headCommitSha ? { headCommitSha: identity.headCommitSha } : {}),
126
+ isPrimary: true,
127
+ };
128
+ // The wire contract accepts a provider ID or a hashed remote even when an
129
+ // internal Zeye repository mapping is not known locally.
130
+ return reference.repositoryId || reference.providerRepositoryId || reference.remoteUrlHash ? [reference] : [];
131
+ }
132
+ function lockPath(directory) { return (0, node_path_1.join)(directory, '.capture.lock'); }
133
+ async function captureLockStatus(directory) {
134
+ try {
135
+ const raw = await (0, promises_1.readFile)(lockPath(directory), 'utf8');
136
+ const value = JSON.parse(raw);
137
+ const pid = typeof value.pid === 'number' && Number.isInteger(value.pid) && value.pid > 0 ? value.pid : undefined;
138
+ if (!pid)
139
+ return { held: true, stale: true };
140
+ try {
141
+ process.kill(pid, 0);
142
+ return { held: true, pid };
143
+ }
144
+ catch (error) {
145
+ return { held: true, pid, stale: error.code === 'ESRCH' };
146
+ }
147
+ }
148
+ catch (error) {
149
+ if (error.code === 'ENOENT')
150
+ return { held: false };
151
+ return { held: true, stale: true };
152
+ }
153
+ }
154
+ function delay(milliseconds) {
155
+ return new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds));
156
+ }
157
+ async function acquireCaptureLock(directory, options = {}) {
158
+ await ensurePrivateDirectory(directory);
159
+ const path = lockPath(directory);
160
+ const maxWaitMs = options.maxWaitMs ?? 5_000;
161
+ const initialRetryDelayMs = options.initialRetryDelayMs ?? 25;
162
+ const maxRetryDelayMs = options.maxRetryDelayMs ?? 500;
163
+ const deadline = Date.now() + maxWaitMs;
164
+ let retryDelayMs = initialRetryDelayMs;
165
+ for (;;) {
166
+ try {
167
+ const handle = await (0, promises_1.open)(path, 'wx', 0o600);
168
+ await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
169
+ await handle.close();
170
+ return { async release() { await (0, promises_1.rm)(path, { force: true }); } };
171
+ }
172
+ catch (error) {
173
+ if (error.code !== 'EEXIST')
174
+ throw error;
175
+ const status = await captureLockStatus(directory);
176
+ if (status.stale) {
177
+ await (0, promises_1.rm)(path, { force: true });
178
+ continue;
179
+ }
180
+ if (Date.now() >= deadline)
181
+ throw new Error(`Capture already running${status.pid ? ` (pid ${status.pid})` : ''}`);
182
+ await delay(Math.min(retryDelayMs, Math.max(0, deadline - Date.now())));
183
+ retryDelayMs = Math.min(maxRetryDelayMs, retryDelayMs * 2);
184
+ }
185
+ }
186
+ }
187
+ async function withCaptureLock(directory, operation, options) {
188
+ const lock = await acquireCaptureLock(directory, options);
189
+ try {
190
+ return await operation();
191
+ }
192
+ finally {
193
+ await lock.release();
194
+ }
195
+ }
196
+ async function settingsPathIsTracked(repositoryRoot, settingsPath) {
197
+ if (!(0, node_path_1.isAbsolute)(settingsPath))
198
+ return false;
199
+ const rel = (0, node_path_1.relative)(repositoryRoot, settingsPath);
200
+ if (!rel || rel.startsWith('..') || (0, node_path_1.isAbsolute)(rel))
201
+ return false;
202
+ return Boolean(await git(repositoryRoot, ['ls-files', '--error-unmatch', '--', rel]));
203
+ }
204
+ function zeyeHookGroup(command) {
205
+ return { matcher: '', hooks: [{ type: 'command', command }] };
206
+ }
207
+ function isZeyeHookGroup(value, command) {
208
+ if (!value || typeof value !== 'object')
209
+ return false;
210
+ const hooks = value.hooks;
211
+ return Array.isArray(hooks) && hooks.some(hook => Boolean(hook && typeof hook === 'object' && hook.type === 'command' && hook.command === command));
212
+ }
213
+ async function installClaudeHooks(input) {
214
+ const repositoryRoot = (0, node_path_1.resolve)(input.repositoryRoot);
215
+ const settingsPath = (0, node_path_1.resolve)(input.settingsPath || (0, node_path_1.join)(repositoryRoot, '.claude', 'settings.local.json'));
216
+ const command = input.command?.trim() || 'zeye-capture claude-hook';
217
+ if (await settingsPathIsTracked(repositoryRoot, settingsPath))
218
+ throw new Error(`Refusing to modify tracked Claude settings: ${settingsPath}. Use an untracked settings.local.json path.`);
219
+ const settings = await readJsonObject(settingsPath);
220
+ const hooks = settings.hooks;
221
+ if (hooks !== undefined && (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)))
222
+ throw new Error(`Claude settings hooks must be an object: ${settingsPath}`);
223
+ const hookMap = (hooks || {});
224
+ const installedEvents = [];
225
+ for (const event of ZEYE_HOOK_EVENTS) {
226
+ const groups = hookMap[event];
227
+ if (groups !== undefined && !Array.isArray(groups))
228
+ throw new Error(`Claude settings hook ${event} must be an array: ${settingsPath}`);
229
+ const current = (groups || []);
230
+ if (!current.some(group => isZeyeHookGroup(group, command))) {
231
+ hookMap[event] = [...current, zeyeHookGroup(command)];
232
+ installedEvents.push(event);
233
+ }
234
+ }
235
+ settings.hooks = hookMap;
236
+ await writePrivateJson(settingsPath, settings);
237
+ const config = await readLocalHostConfig(input.configPath);
238
+ config.claudeInstalls = [...config.claudeInstalls.filter(entry => entry.repositoryRoot !== repositoryRoot || entry.settingsPath !== settingsPath), { repositoryRoot, settingsPath, command }];
239
+ await writeLocalHostConfig(config, input.configPath);
240
+ return { settingsPath, installedEvents };
241
+ }
242
+ async function uninstallClaudeHooks(input) {
243
+ const repositoryRoot = (0, node_path_1.resolve)(input.repositoryRoot);
244
+ const config = await readLocalHostConfig(input.configPath);
245
+ const existing = config.claudeInstalls.find(entry => entry.repositoryRoot === repositoryRoot && (!input.settingsPath || (0, node_path_1.resolve)(input.settingsPath) === entry.settingsPath));
246
+ const settingsPath = (0, node_path_1.resolve)(input.settingsPath || existing?.settingsPath || (0, node_path_1.join)(repositoryRoot, '.claude', 'settings.local.json'));
247
+ const command = input.command?.trim() || existing?.command || 'zeye-capture claude-hook';
248
+ const settings = await readJsonObject(settingsPath);
249
+ const hooks = settings.hooks;
250
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
251
+ return { settingsPath, removedEvents: [] };
252
+ const hookMap = hooks;
253
+ const removedEvents = [];
254
+ for (const event of ZEYE_HOOK_EVENTS) {
255
+ const groups = hookMap[event];
256
+ if (!Array.isArray(groups))
257
+ continue;
258
+ const next = groups.filter(group => !isZeyeHookGroup(group, command));
259
+ if (next.length !== groups.length)
260
+ removedEvents.push(event);
261
+ if (next.length)
262
+ hookMap[event] = next;
263
+ else
264
+ delete hookMap[event];
265
+ }
266
+ settings.hooks = hookMap;
267
+ await writePrivateJson(settingsPath, settings);
268
+ config.claudeInstalls = config.claudeInstalls.filter(entry => !(entry.repositoryRoot === repositoryRoot && entry.settingsPath === settingsPath && entry.command === command));
269
+ await writeLocalHostConfig(config, input.configPath);
270
+ return { settingsPath, removedEvents };
271
+ }
272
+ async function installCodexSource(input) {
273
+ const config = await readLocalHostConfig(input.configPath);
274
+ const source = { repositoryRoot: (0, node_path_1.resolve)(input.repositoryRoot), jsonlPath: (0, node_path_1.resolve)(input.jsonlPath) };
275
+ config.codexSources = [...config.codexSources.filter(entry => entry.repositoryRoot !== source.repositoryRoot || entry.jsonlPath !== source.jsonlPath), source];
276
+ await writeLocalHostConfig(config, input.configPath);
277
+ }
278
+ async function uninstallCodexSource(input) {
279
+ const config = await readLocalHostConfig(input.configPath);
280
+ const repositoryRoot = (0, node_path_1.resolve)(input.repositoryRoot);
281
+ const jsonlPath = input.jsonlPath ? (0, node_path_1.resolve)(input.jsonlPath) : undefined;
282
+ const before = config.codexSources.length;
283
+ config.codexSources = config.codexSources.filter(entry => entry.repositoryRoot !== repositoryRoot || (jsonlPath !== undefined && entry.jsonlPath !== jsonlPath));
284
+ await writeLocalHostConfig(config, input.configPath);
285
+ return before - config.codexSources.length;
286
+ }
287
+ function configuredSourcesForRepository(config, repositoryRoot) {
288
+ return config.codexSources.filter(source => source.repositoryRoot === (0, node_path_1.resolve)(repositoryRoot));
289
+ }
@@ -0,0 +1,18 @@
1
+ import type { MetadataCaptureEvent } from "./types";
2
+ /** Parse provider-visible Claude transcript JSONL without hidden thinking blocks. */
3
+ export declare function fromClaudeTranscriptJsonl(input: unknown, fallbackSessionId?: string): MetadataCaptureEvent | null;
4
+ /** Parse a Claude Code hook payload without retaining prompt/tool/file content. */
5
+ export declare function fromClaudeCodeHook(input: unknown, captureContent?: boolean): MetadataCaptureEvent | null;
6
+ /**
7
+ * Parse one JSONL record from Codex local session/rollout logs. The Codex log
8
+ * format evolves, so unknown record kinds become a safe capture warning rather
9
+ * than being misrepresented as a complete observation.
10
+ */
11
+ export declare function fromCodexJsonl(input: unknown, sourceFile?: string, captureContent?: boolean): MetadataCaptureEvent | null;
12
+ /**
13
+ * Convert a deliberately small event emitted by a VS Code/Copilot companion
14
+ * extension. This is not a transcript importer: workspace paths, chat text,
15
+ * tool arguments, file bodies, and authentication state are all excluded at
16
+ * the extension boundary and again by the outbox redactor.
17
+ */
18
+ export declare function fromVSCodeCopilotEvent(input: unknown): MetadataCaptureEvent | null;