@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,470 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const fileOutbox_1 = require("./fileOutbox");
5
+ const node_path_1 = require("node:path");
6
+ const promises_1 = require("node:fs/promises");
7
+ const host_1 = require("./host");
8
+ const localHost_1 = require("./localHost");
9
+ const providers_1 = require("./providers");
10
+ const redaction_1 = require("./redaction");
11
+ const transport_1 = require("./transport");
12
+ const service_1 = require("./service");
13
+ function required(name) {
14
+ const value = process.env[name]?.trim();
15
+ if (!value)
16
+ throw new Error(`${name} is required`);
17
+ return value;
18
+ }
19
+ function parseFlags(args) {
20
+ const positionals = [];
21
+ const flags = {};
22
+ for (let index = 0; index < args.length; index += 1) {
23
+ const value = args[index];
24
+ if (!value.startsWith("--")) {
25
+ positionals.push(value);
26
+ continue;
27
+ }
28
+ const key = value.slice(2);
29
+ const next = args[index + 1];
30
+ if (next && !next.startsWith("--")) {
31
+ flags[key] = next;
32
+ index += 1;
33
+ }
34
+ else
35
+ flags[key] = true;
36
+ }
37
+ return { positionals, flags };
38
+ }
39
+ function optional(flags, name) {
40
+ const value = flags[name];
41
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
42
+ }
43
+ function print(value) {
44
+ process.stdout.write(`${JSON.stringify(value)}\n`);
45
+ }
46
+ async function readStdin() {
47
+ const chunks = [];
48
+ for await (const chunk of process.stdin)
49
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
50
+ return Buffer.concat(chunks).toString("utf8");
51
+ }
52
+ async function resolveRepository(repoPath) {
53
+ const identity = await (0, localHost_1.discoverRepositoryIdentity)(repoPath, {
54
+ providerRepositoryId: process.env.ZEYE_CAPTURE_PROVIDER_REPOSITORY_ID,
55
+ });
56
+ if (!identity)
57
+ throw new Error(`No Git repository found at ${repoPath}. Run this command from a repository or pass --repo PATH.`);
58
+ return identity;
59
+ }
60
+ function captureContext(identity) {
61
+ return {
62
+ repositories: (0, localHost_1.captureRepositoryReference)(identity, process.env.ZEYE_CAPTURE_REPOSITORY_ID),
63
+ };
64
+ }
65
+ async function runtimeConfig(identity) {
66
+ const requiredNames = [
67
+ "ZEYE_CAPTURE_ENDPOINT",
68
+ "ZEYE_CAPTURE_TOKEN",
69
+ "ZEYE_CAPTURE_ORGANIZATION_ID",
70
+ "ZEYE_CAPTURE_INSTALLATION_ID",
71
+ "ZEYE_CAPTURE_ACTOR_JSON",
72
+ ];
73
+ if (requiredNames.every((name) => !process.env[name]))
74
+ return null;
75
+ for (const name of requiredNames)
76
+ required(name);
77
+ let actor;
78
+ try {
79
+ actor = JSON.parse(required("ZEYE_CAPTURE_ACTOR_JSON"));
80
+ }
81
+ catch {
82
+ throw new Error("ZEYE_CAPTURE_ACTOR_JSON must be valid JSON");
83
+ }
84
+ const contentConsentId = process.env.ZEYE_CAPTURE_CONTENT_CONSENT_ID?.trim();
85
+ if (contentConsentId && process.env.ZEYE_CAPTURE_TRANSCRIPT_CONSENT !== "yes")
86
+ throw new Error("ZEYE_CAPTURE_TRANSCRIPT_CONSENT=yes is required with a content consent ID");
87
+ return {
88
+ endpoint: (0, transport_1.validateCaptureEndpoint)(required("ZEYE_CAPTURE_ENDPOINT")),
89
+ token: required("ZEYE_CAPTURE_TOKEN"),
90
+ organizationId: required("ZEYE_CAPTURE_ORGANIZATION_ID"),
91
+ captureInstallationId: required("ZEYE_CAPTURE_INSTALLATION_ID"),
92
+ actor: actor,
93
+ privacy: {
94
+ classification: "INTERNAL",
95
+ contentMode: contentConsentId ? "REDACTED_CONTENT" : "METADATA_ONLY",
96
+ redactionApplied: true,
97
+ retentionClass: "STANDARD",
98
+ visibility: "ACTOR_ONLY",
99
+ ...(contentConsentId ? { contentConsentId } : {}),
100
+ },
101
+ // This is only a compatibility fallback for records written before capture
102
+ // context became durable. New records receive repository facts at enqueue.
103
+ repositories: captureContext(identity).repositories,
104
+ };
105
+ }
106
+ async function flushIfConfigured(outbox, configuration, adapter) {
107
+ if (!configuration)
108
+ return { uploadConfigured: false, delivered: 0, failed: 0, deferred: 0 };
109
+ const transport = new transport_1.CaptureHttpTransport(outbox, configuration);
110
+ await transport.publishManifest((0, transport_1.capabilityManifestForAdapter)(adapter, Boolean(configuration.privacy.contentConsentId)));
111
+ return { uploadConfigured: true, ...(await transport.flush()) };
112
+ }
113
+ async function withRepositoryCapture(identity, operation) {
114
+ const directory = (0, localHost_1.localOutboxDirectory)(identity.repositoryRoot);
115
+ const context = captureContext(identity);
116
+ return (0, localHost_1.withCaptureLock)(directory, async () => {
117
+ const runtime = await runtimeConfig(identity);
118
+ return operation(new fileOutbox_1.FileOutbox(directory, runtime?.privacy.contentMode === "REDACTED_CONTENT"
119
+ ? redaction_1.consentedTranscriptRedactor
120
+ : redaction_1.metadataRedactor), runtime, context);
121
+ });
122
+ }
123
+ async function status(repositoryRoot) {
124
+ const [config, identity] = await Promise.all([
125
+ (0, localHost_1.readLocalHostConfig)(),
126
+ resolveRepository(repositoryRoot),
127
+ ]);
128
+ const outboxDirectory = (0, localHost_1.localOutboxDirectory)(identity.repositoryRoot);
129
+ const [records, lock] = await Promise.all([
130
+ new fileOutbox_1.FileOutbox(outboxDirectory, redaction_1.metadataRedactor).list(),
131
+ (0, localHost_1.captureLockStatus)(outboxDirectory),
132
+ ]);
133
+ const counts = records.reduce((result, record) => {
134
+ result[record.status] = (result[record.status] || 0) + 1;
135
+ return result;
136
+ }, {});
137
+ const configured = await runtimeConfig(identity);
138
+ print({
139
+ repository: {
140
+ fullName: identity.fullName || null,
141
+ branch: identity.branch || null,
142
+ headCommitSha: identity.headCommitSha || null,
143
+ providerRepositoryId: identity.providerRepositoryId || null,
144
+ },
145
+ outbox: {
146
+ directory: outboxDirectory,
147
+ records: records.length,
148
+ counts,
149
+ lock,
150
+ },
151
+ claudeHooksInstalled: config.claudeInstalls.some((entry) => entry.repositoryRoot === identity.repositoryRoot),
152
+ codexSources: (0, localHost_1.configuredSourcesForRepository)(config, identity.repositoryRoot).map((source) => ({ jsonlPath: source.jsonlPath })),
153
+ uploadConfigured: Boolean(configured),
154
+ diagnostics: configured
155
+ ? []
156
+ : [
157
+ "Upload is offline-only until all ZEYE_CAPTURE_* runtime variables are supplied. The token is never stored by this CLI.",
158
+ ],
159
+ });
160
+ }
161
+ async function sync(repositoryRoot, exitOnFailure = true) {
162
+ const identity = await resolveRepository(repositoryRoot);
163
+ const config = await (0, localHost_1.readLocalHostConfig)();
164
+ const sources = (0, localHost_1.configuredSourcesForRepository)(config, identity.repositoryRoot);
165
+ const result = await withRepositoryCapture(identity, async (outbox, runtime, context) => {
166
+ const imports = [];
167
+ for (const source of sources) {
168
+ try {
169
+ imports.push({
170
+ jsonlPath: source.jsonlPath,
171
+ ...(await (0, host_1.importCodexJsonl)(outbox, source.jsonlPath, context, runtime?.privacy.contentMode === "REDACTED_CONTENT")),
172
+ });
173
+ }
174
+ catch (error) {
175
+ imports.push({
176
+ jsonlPath: source.jsonlPath,
177
+ error: error instanceof Error
178
+ ? error.message.replace(source.jsonlPath, "[configured JSONL path]")
179
+ : "read_failed",
180
+ });
181
+ }
182
+ }
183
+ if (!runtime)
184
+ return {
185
+ imports,
186
+ uploadConfigured: false,
187
+ delivered: 0,
188
+ failed: 0,
189
+ deferred: 0,
190
+ heartbeat: "not_configured",
191
+ };
192
+ try {
193
+ const transport = new transport_1.CaptureHttpTransport(outbox, runtime);
194
+ await transport.publishManifest((0, transport_1.capabilityManifestForAdapter)("codex", Boolean(runtime.privacy.contentConsentId)));
195
+ await transport.heartbeat();
196
+ return {
197
+ imports,
198
+ uploadConfigured: true,
199
+ heartbeat: "sent",
200
+ ...(await transport.flush()),
201
+ };
202
+ }
203
+ catch (error) {
204
+ return {
205
+ imports,
206
+ uploadConfigured: true,
207
+ delivered: 0,
208
+ failed: 1,
209
+ deferred: 0,
210
+ heartbeat: "failed",
211
+ error: error instanceof Error ? error.message : "capture_sync_failed",
212
+ };
213
+ }
214
+ });
215
+ print(result);
216
+ if (exitOnFailure &&
217
+ (result.imports.some((entry) => entry.error) ||
218
+ result.failed > 0 ||
219
+ result.deferred > 0 ||
220
+ result.heartbeat === "failed"))
221
+ process.exitCode = 1;
222
+ }
223
+ async function heartbeat(repositoryRoot) {
224
+ const identity = await resolveRepository(repositoryRoot);
225
+ const result = await withRepositoryCapture(identity, async (outbox, runtime) => {
226
+ if (!runtime)
227
+ return {
228
+ uploadConfigured: false,
229
+ heartbeat: "not_configured",
230
+ pending: (await outbox.nextBatch(10_000)).length,
231
+ };
232
+ const transport = new transport_1.CaptureHttpTransport(outbox, runtime);
233
+ await transport.heartbeat();
234
+ return {
235
+ uploadConfigured: true,
236
+ heartbeat: "sent",
237
+ pending: (await outbox.nextBatch(10_000)).length,
238
+ };
239
+ });
240
+ print(result);
241
+ }
242
+ function usage() {
243
+ throw new Error("Usage: zeye-capture <install|uninstall|service|status|sync|heartbeat|claude-hook|codex-jsonl|vscode-copilot-event> [claude|codex|install|uninstall] [--repo PATH]");
244
+ }
245
+ function serviceEnrollment(intervalSeconds) {
246
+ return {
247
+ endpoint: required("ZEYE_CAPTURE_ENDPOINT"),
248
+ token: required("ZEYE_CAPTURE_TOKEN"),
249
+ organizationId: required("ZEYE_CAPTURE_ORGANIZATION_ID"),
250
+ captureInstallationId: required("ZEYE_CAPTURE_INSTALLATION_ID"),
251
+ actorJson: required("ZEYE_CAPTURE_ACTOR_JSON"),
252
+ intervalSeconds,
253
+ ...(process.env.ZEYE_CAPTURE_CONTENT_CONSENT_ID
254
+ ? {
255
+ contentConsentId: required("ZEYE_CAPTURE_CONTENT_CONSENT_ID"),
256
+ transcriptConsent: "yes",
257
+ }
258
+ : {}),
259
+ };
260
+ }
261
+ async function runService(repositoryRoot, configPath) {
262
+ const enrollment = await (0, service_1.readServiceEnrollment)(configPath);
263
+ (0, service_1.applyServiceEnrollment)(enrollment);
264
+ let stopping = false;
265
+ process.once("SIGINT", () => {
266
+ stopping = true;
267
+ });
268
+ process.once("SIGTERM", () => {
269
+ stopping = true;
270
+ });
271
+ while (!stopping) {
272
+ await sync(repositoryRoot, false);
273
+ if (stopping)
274
+ break;
275
+ await new Promise((resolveWait) => setTimeout(resolveWait, enrollment.intervalSeconds * 1000));
276
+ }
277
+ }
278
+ async function main() {
279
+ const { positionals, flags } = parseFlags(process.argv.slice(2));
280
+ const command = positionals.shift();
281
+ const repoPath = optional(flags, "repo") || process.cwd();
282
+ if (command === "service-run") {
283
+ const configPath = optional(flags, "config");
284
+ if (!configPath)
285
+ throw new Error("Service configuration is required");
286
+ await runService(repoPath, configPath);
287
+ return;
288
+ }
289
+ if (command === "service") {
290
+ const action = positionals.shift();
291
+ const identity = await resolveRepository(repoPath);
292
+ if (action === "install") {
293
+ const intervalSeconds = Number(optional(flags, "interval") || "60");
294
+ if (!Number.isInteger(intervalSeconds) ||
295
+ intervalSeconds < 15 ||
296
+ intervalSeconds > 3600)
297
+ throw new Error("--interval must be between 15 and 3600 seconds");
298
+ if (process.env.ZEYE_CAPTURE_CONTENT_CONSENT_ID &&
299
+ process.env.ZEYE_CAPTURE_TRANSCRIPT_CONSENT !== "yes")
300
+ throw new Error("Transcript service installation requires ZEYE_CAPTURE_TRANSCRIPT_CONSENT=yes");
301
+ const files = await (0, service_1.installCaptureService)({
302
+ repositoryRoot: identity.repositoryRoot,
303
+ enrollment: serviceEnrollment(intervalSeconds),
304
+ cliPath: (0, node_path_1.resolve)(process.argv[1]),
305
+ });
306
+ print({
307
+ installed: true,
308
+ service: files.serviceId,
309
+ kind: files.kind,
310
+ intervalSeconds,
311
+ transcriptCapture: Boolean(process.env.ZEYE_CAPTURE_CONTENT_CONSENT_ID),
312
+ });
313
+ return;
314
+ }
315
+ if (action === "uninstall") {
316
+ const files = await (0, service_1.uninstallCaptureService)(identity.repositoryRoot);
317
+ print({
318
+ uninstalled: true,
319
+ service: files.serviceId,
320
+ credentialsRemoved: true,
321
+ });
322
+ return;
323
+ }
324
+ throw new Error("Usage: zeye-capture service <install|uninstall> [--repo PATH] [--interval SECONDS]");
325
+ }
326
+ if (command === "install") {
327
+ const target = positionals.shift();
328
+ const identity = await resolveRepository(repoPath);
329
+ if (target === "claude") {
330
+ const result = await (0, localHost_1.installClaudeHooks)({
331
+ repositoryRoot: identity.repositoryRoot,
332
+ settingsPath: optional(flags, "settings"),
333
+ command: optional(flags, "command"),
334
+ });
335
+ print({
336
+ installed: "claude",
337
+ ...result,
338
+ outboxDirectory: (0, localHost_1.localOutboxDirectory)(identity.repositoryRoot),
339
+ });
340
+ return;
341
+ }
342
+ if (target === "codex") {
343
+ const jsonlPath = optional(flags, "jsonl");
344
+ if (!jsonlPath)
345
+ throw new Error("Usage: zeye-capture install codex --jsonl PATH [--repo PATH]");
346
+ await (0, localHost_1.installCodexSource)({
347
+ repositoryRoot: identity.repositoryRoot,
348
+ jsonlPath,
349
+ });
350
+ print({
351
+ installed: "codex",
352
+ jsonlPath,
353
+ syncCommand: "zeye-capture sync",
354
+ });
355
+ return;
356
+ }
357
+ usage();
358
+ }
359
+ if (command === "uninstall") {
360
+ const target = positionals.shift();
361
+ const identity = await resolveRepository(repoPath);
362
+ if (target === "claude") {
363
+ print({
364
+ uninstalled: "claude",
365
+ ...(await (0, localHost_1.uninstallClaudeHooks)({
366
+ repositoryRoot: identity.repositoryRoot,
367
+ settingsPath: optional(flags, "settings"),
368
+ command: optional(flags, "command"),
369
+ })),
370
+ });
371
+ return;
372
+ }
373
+ if (target === "codex") {
374
+ print({
375
+ uninstalled: "codex",
376
+ removedSources: await (0, localHost_1.uninstallCodexSource)({
377
+ repositoryRoot: identity.repositoryRoot,
378
+ jsonlPath: optional(flags, "jsonl"),
379
+ }),
380
+ });
381
+ return;
382
+ }
383
+ usage();
384
+ }
385
+ if (command === "status") {
386
+ await status(repoPath);
387
+ return;
388
+ }
389
+ if (command === "sync") {
390
+ await sync(repoPath);
391
+ return;
392
+ }
393
+ if (command === "heartbeat") {
394
+ await heartbeat(repoPath);
395
+ return;
396
+ }
397
+ const identity = await resolveRepository(repoPath);
398
+ if (command === "claude-hook") {
399
+ const source = await readStdin();
400
+ const hookPayload = JSON.parse(source);
401
+ print(await withRepositoryCapture(identity, async (outbox, runtime, context) => {
402
+ const capturesContent = runtime?.privacy.contentMode === "REDACTED_CONTENT";
403
+ const transcriptPath = typeof hookPayload.transcript_path === "string"
404
+ ? hookPayload.transcript_path
405
+ : typeof hookPayload.transcriptPath === "string"
406
+ ? hookPayload.transcriptPath
407
+ : undefined;
408
+ let transcriptImported = 0;
409
+ let transcriptSkipped = 0;
410
+ if (capturesContent && transcriptPath) {
411
+ const rawTranscript = await (0, promises_1.readFile)(transcriptPath, "utf8");
412
+ for (const line of rawTranscript.split(/\r?\n/).filter(Boolean)) {
413
+ try {
414
+ const observation = (0, providers_1.fromClaudeTranscriptJsonl)(JSON.parse(line), typeof hookPayload.session_id === "string"
415
+ ? hookPayload.session_id
416
+ : undefined);
417
+ if (!observation) {
418
+ transcriptSkipped += 1;
419
+ continue;
420
+ }
421
+ const result = await outbox.enqueue(observation, context);
422
+ if (result.status === "ENQUEUED")
423
+ transcriptImported += 1;
424
+ else
425
+ transcriptSkipped += 1;
426
+ }
427
+ catch {
428
+ transcriptSkipped += 1;
429
+ }
430
+ }
431
+ }
432
+ const event = (0, providers_1.fromClaudeCodeHook)(hookPayload, capturesContent && !transcriptPath);
433
+ if (event)
434
+ await outbox.enqueue(event, context);
435
+ return {
436
+ transcriptImported,
437
+ transcriptSkipped,
438
+ ...(await flushIfConfigured(outbox, runtime, "claude-code")),
439
+ };
440
+ }));
441
+ return;
442
+ }
443
+ if (command === "codex-jsonl") {
444
+ const filePath = positionals.shift();
445
+ if (!filePath)
446
+ throw new Error("Usage: zeye-capture codex-jsonl PATH [--repo PATH]");
447
+ print(await withRepositoryCapture(identity, async (outbox, runtime, context) => ({
448
+ ...(await (0, host_1.importCodexJsonl)(outbox, filePath, context, runtime?.privacy.contentMode === "REDACTED_CONTENT")),
449
+ ...(await flushIfConfigured(outbox, runtime, "codex")),
450
+ })));
451
+ return;
452
+ }
453
+ if (command === "vscode-copilot-event") {
454
+ const source = await readStdin();
455
+ print(await withRepositoryCapture(identity, async (outbox, runtime, context) => {
456
+ const event = (0, providers_1.fromVSCodeCopilotEvent)(JSON.parse(source));
457
+ if (event)
458
+ await outbox.enqueue(event, context);
459
+ return flushIfConfigured(outbox, runtime, "vscode-copilot");
460
+ }));
461
+ return;
462
+ }
463
+ usage();
464
+ }
465
+ main().catch((error) => {
466
+ // Never print event payloads, paths, source records, actor metadata, or
467
+ // enrollment tokens. Details are deliberately safe enough for hook stderr.
468
+ process.stderr.write(`Zeye capture failed: ${error instanceof Error ? error.message.replace(/zem_[A-Za-z0-9_-]+/g, "[REDACTED]") : "unknown error"}\n`);
469
+ process.exitCode = 1;
470
+ });
@@ -0,0 +1,22 @@
1
+ import type { CaptureEvent, CaptureDeliveryContext, DeliveryRecord, DurableOutbox, EnqueueResult, RedactionBoundary, SourceCheckpoint } from './types';
2
+ export declare class FileOutbox implements DurableOutbox {
3
+ private readonly redactor;
4
+ private readonly statePath;
5
+ private writeQueue;
6
+ constructor(directory: string, redactor: RedactionBoundary);
7
+ enqueue(event: CaptureEvent, context?: CaptureDeliveryContext): Promise<EnqueueResult>;
8
+ nextBatch(limit: number): Promise<DeliveryRecord[]>;
9
+ markAttempt(deliveryId: string, error?: string): Promise<DeliveryRecord>;
10
+ acknowledge(deliveryId: string, receiptId: string): Promise<DeliveryRecord>;
11
+ checkpoint(adapter: string, scope: string, cursor: string): Promise<SourceCheckpoint>;
12
+ getCheckpoint(adapter: string, scope: string): Promise<SourceCheckpoint | undefined>;
13
+ list(): Promise<DeliveryRecord[]>;
14
+ private read;
15
+ private update;
16
+ private readState;
17
+ private writeState;
18
+ private sourceKey;
19
+ private checkpointKey;
20
+ private requireDelivery;
21
+ private validateEvent;
22
+ }