@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,219 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FileOutbox = void 0;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_path_1 = require("node:path");
6
+ const canonical_1 = require("./canonical");
7
+ const zeye_shared_1 = require("@zewstid/zeye-shared");
8
+ const EMPTY_STATE = { version: 1, deliveries: {}, checkpoints: {} };
9
+ class FileOutbox {
10
+ redactor;
11
+ statePath;
12
+ writeQueue = Promise.resolve();
13
+ constructor(directory, redactor) {
14
+ this.redactor = redactor;
15
+ this.statePath = (0, node_path_1.join)(directory, 'outbox-v1.json');
16
+ }
17
+ async enqueue(event, context) {
18
+ this.validateEvent(event);
19
+ const redacted = await this.redactor.redact(event);
20
+ // Parse and clone the context before it reaches durable storage. This keeps
21
+ // provider event IDs and the repository checkout facts in the same
22
+ // idempotency boundary without retaining a raw remote URL or local path.
23
+ const captureRepositories = context
24
+ ? zeye_shared_1.CaptureRepositoryReference.array().max(100).parse(context.repositories).map(repository => ({ ...repository }))
25
+ : undefined;
26
+ const sourceKey = this.sourceKey(event);
27
+ const payloadHash = (0, canonical_1.sha256)({
28
+ adapter: event.adapter,
29
+ sessionId: event.sessionId,
30
+ sourceEventId: event.sourceEventId,
31
+ ...(event.clientName ? { clientName: event.clientName } : {}),
32
+ ...(event.clientVersion ? { clientVersion: event.clientVersion } : {}),
33
+ ...(event.sourceGenerationId ? { sourceGenerationId: event.sourceGenerationId } : {}),
34
+ ...(event.relationship ? { relationship: event.relationship } : {}),
35
+ ...(event.relatedSourceSessionId ? { relatedSourceSessionId: event.relatedSourceSessionId } : {}),
36
+ type: event.type,
37
+ occurredAt: event.occurredAt,
38
+ ...(captureRepositories ? { captureRepositories } : {}),
39
+ payload: redacted.payload,
40
+ ...(redacted.transcript ? { transcript: redacted.transcript } : {}),
41
+ });
42
+ return this.update(async (state) => {
43
+ const existing = Object.values(state.deliveries).find(record => record.sourceKey === sourceKey || (event.deliveryId !== undefined && record.deliveryId === event.deliveryId));
44
+ if (existing) {
45
+ return {
46
+ status: existing.payloadHash === payloadHash ? 'DUPLICATE' : 'CONFLICT',
47
+ record: { ...existing, status: existing.payloadHash === payloadHash ? existing.status : 'CONFLICT' },
48
+ };
49
+ }
50
+ const now = new Date().toISOString();
51
+ const sequence = Object.values(state.deliveries)
52
+ .filter(record => record.adapter === event.adapter && record.sessionId === event.sessionId && record.sourceGenerationId === event.sourceGenerationId)
53
+ .reduce((highest, record) => Math.max(highest, record.sequence), -1) + 1;
54
+ const record = {
55
+ deliveryId: event.deliveryId ?? (0, canonical_1.newDeliveryId)(),
56
+ sourceKey,
57
+ adapter: event.adapter,
58
+ sessionId: event.sessionId,
59
+ sourceEventId: event.sourceEventId,
60
+ ...(event.clientName ? { clientName: event.clientName } : {}),
61
+ ...(event.clientVersion ? { clientVersion: event.clientVersion } : {}),
62
+ ...(event.sourceGenerationId ? { sourceGenerationId: event.sourceGenerationId } : {}),
63
+ relationship: event.relationship || 'ROOT',
64
+ ...(event.relatedSourceSessionId ? { relatedSourceSessionId: event.relatedSourceSessionId } : {}),
65
+ type: event.type,
66
+ sequence,
67
+ occurredAt: event.occurredAt,
68
+ ...(captureRepositories ? { captureRepositories } : {}),
69
+ payload: redacted.payload,
70
+ ...(redacted.transcript ? { transcript: redacted.transcript } : {}),
71
+ payloadHash,
72
+ redactionCount: redacted.redactionCount,
73
+ redactionReasons: redacted.redactionReasons ?? [],
74
+ status: 'PENDING',
75
+ attempts: 0,
76
+ createdAt: now,
77
+ updatedAt: now,
78
+ };
79
+ state.deliveries[record.deliveryId] = record;
80
+ return { status: 'ENQUEUED', record: { ...record } };
81
+ });
82
+ }
83
+ async nextBatch(limit) {
84
+ if (!Number.isInteger(limit) || limit < 1)
85
+ throw new RangeError('limit must be a positive integer');
86
+ return this.read(state => Object.values(state.deliveries)
87
+ .filter(record => record.status === 'PENDING' || record.status === 'RETRY')
88
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt))
89
+ .slice(0, limit)
90
+ .map(record => ({ ...record })));
91
+ }
92
+ async markAttempt(deliveryId, error) {
93
+ return this.update(async (state) => {
94
+ const record = this.requireDelivery(state, deliveryId);
95
+ if (record.status === 'ACKNOWLEDGED' || record.status === 'CONFLICT')
96
+ return { ...record };
97
+ record.attempts += 1;
98
+ record.lastAttemptAt = new Date().toISOString();
99
+ record.updatedAt = record.lastAttemptAt;
100
+ if (error) {
101
+ record.status = 'RETRY';
102
+ record.lastError = error;
103
+ }
104
+ else {
105
+ record.status = 'PENDING';
106
+ delete record.lastError;
107
+ }
108
+ return { ...record };
109
+ });
110
+ }
111
+ async acknowledge(deliveryId, receiptId) {
112
+ if (!receiptId.trim())
113
+ throw new Error('receiptId is required');
114
+ return this.update(async (state) => {
115
+ const record = this.requireDelivery(state, deliveryId);
116
+ if (record.status === 'CONFLICT')
117
+ throw new Error(`Cannot acknowledge conflicted delivery ${deliveryId}`);
118
+ if (record.status === 'ACKNOWLEDGED') {
119
+ if (record.receiptId !== receiptId)
120
+ throw new Error(`Delivery ${deliveryId} was acknowledged with a different receipt`);
121
+ return { ...record };
122
+ }
123
+ const now = new Date().toISOString();
124
+ record.status = 'ACKNOWLEDGED';
125
+ record.receiptId = receiptId;
126
+ record.acknowledgedAt = now;
127
+ record.updatedAt = now;
128
+ delete record.lastError;
129
+ return { ...record };
130
+ });
131
+ }
132
+ async checkpoint(adapter, scope, cursor) {
133
+ if (!adapter.trim() || !scope.trim() || !cursor.trim())
134
+ throw new Error('adapter, scope, and cursor are required');
135
+ return this.update(async (state) => {
136
+ const checkpoint = { adapter, scope, cursor, updatedAt: new Date().toISOString() };
137
+ state.checkpoints[this.checkpointKey(adapter, scope)] = checkpoint;
138
+ return { ...checkpoint };
139
+ });
140
+ }
141
+ async getCheckpoint(adapter, scope) {
142
+ return this.read(state => {
143
+ const checkpoint = state.checkpoints[this.checkpointKey(adapter, scope)];
144
+ return checkpoint ? { ...checkpoint } : undefined;
145
+ });
146
+ }
147
+ async list() {
148
+ return this.read(state => Object.values(state.deliveries)
149
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt))
150
+ .map(record => ({ ...record })));
151
+ }
152
+ async read(operation) {
153
+ await this.writeQueue;
154
+ return operation(await this.readState());
155
+ }
156
+ async update(operation) {
157
+ let result;
158
+ const operationQueue = this.writeQueue.then(async () => {
159
+ const state = await this.readState();
160
+ result = await operation(state);
161
+ await this.writeState(state);
162
+ });
163
+ // A rejected caller operation must not poison the outbox for subsequent
164
+ // hook events. The current caller still receives its original error.
165
+ this.writeQueue = operationQueue.catch(() => undefined);
166
+ await operationQueue;
167
+ return result;
168
+ }
169
+ async readState() {
170
+ try {
171
+ const content = await (0, promises_1.readFile)(this.statePath, 'utf8');
172
+ const parsed = JSON.parse(content);
173
+ if (parsed.version !== 1 || !parsed.deliveries || !parsed.checkpoints)
174
+ throw new Error('Unsupported or corrupt outbox state');
175
+ return parsed;
176
+ }
177
+ catch (error) {
178
+ if (error.code === 'ENOENT')
179
+ return structuredClone(EMPTY_STATE);
180
+ throw error;
181
+ }
182
+ }
183
+ async writeState(state) {
184
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.statePath), { recursive: true, mode: 0o700 });
185
+ const temporaryPath = `${this.statePath}.${process.pid}.${Date.now()}.tmp`;
186
+ await (0, promises_1.writeFile)(temporaryPath, `${(0, canonical_1.canonicalJson)(state)}\n`, { encoding: 'utf8', mode: 0o600 });
187
+ await (0, promises_1.rename)(temporaryPath, this.statePath);
188
+ }
189
+ sourceKey(event) {
190
+ return `${event.adapter}:${event.sessionId}:${event.sourceGenerationId || event.sessionId}:${event.sourceEventId}`;
191
+ }
192
+ checkpointKey(adapter, scope) {
193
+ return `${adapter}:${scope}`;
194
+ }
195
+ requireDelivery(state, deliveryId) {
196
+ const record = state.deliveries[deliveryId];
197
+ if (!record)
198
+ throw new Error(`Unknown delivery: ${deliveryId}`);
199
+ return record;
200
+ }
201
+ validateEvent(event) {
202
+ for (const [key, value] of Object.entries({
203
+ adapter: event.adapter,
204
+ sessionId: event.sessionId,
205
+ sourceEventId: event.sourceEventId,
206
+ occurredAt: event.occurredAt,
207
+ type: event.type,
208
+ })) {
209
+ if (!value?.trim())
210
+ throw new Error(`${key} is required`);
211
+ }
212
+ if (Number.isNaN(Date.parse(event.occurredAt)))
213
+ throw new Error('occurredAt must be an ISO-8601 timestamp');
214
+ if (event.relationship && event.relationship !== 'ROOT' && !event.relatedSourceSessionId?.trim()) {
215
+ throw new Error('relatedSourceSessionId is required for non-root session lineage');
216
+ }
217
+ }
218
+ }
219
+ exports.FileOutbox = FileOutbox;
@@ -0,0 +1,14 @@
1
+ import { type CaptureTransportConfiguration } from './transport';
2
+ import type { CaptureDeliveryContext, DurableOutbox } from './types';
3
+ export interface CodexImportResult {
4
+ imported: number;
5
+ skipped: number;
6
+ deferred: number;
7
+ rotated: boolean;
8
+ }
9
+ /**
10
+ * Imports only unseen Codex JSONL lines. A file truncation/reset is treated as
11
+ * a new source generation rather than silently skipping fresh evidence.
12
+ */
13
+ export declare function importCodexJsonl(outbox: DurableOutbox, filePath: string, context?: CaptureDeliveryContext, captureContent?: boolean): Promise<CodexImportResult>;
14
+ export declare function flushIfConfigured(outbox: DurableOutbox, config: CaptureTransportConfiguration | null): Promise<void>;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.importCodexJsonl = importCodexJsonl;
4
+ exports.flushIfConfigured = flushIfConfigured;
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_path_1 = require("node:path");
7
+ const canonical_1 = require("./canonical");
8
+ const providers_1 = require("./providers");
9
+ const transport_1 = require("./transport");
10
+ function parseCodexCheckpoint(cursor) {
11
+ if (!cursor)
12
+ return null;
13
+ try {
14
+ const parsed = JSON.parse(cursor);
15
+ const completedLines = parsed.completedLines;
16
+ if (parsed.version === 2 && Number.isInteger(completedLines) && completedLines !== undefined && completedLines >= 0 && typeof parsed.sourceGenerationId === 'string' && parsed.sourceGenerationId) {
17
+ return { version: 2, completedLines, lastLineHash: typeof parsed.lastLineHash === 'string' ? parsed.lastLineHash : undefined, sourceGenerationId: parsed.sourceGenerationId };
18
+ }
19
+ }
20
+ catch { /* legacy cursor */ }
21
+ const legacy = /^line:(\d+)$/.exec(cursor);
22
+ return legacy ? { version: 2, completedLines: Number(legacy[1]), sourceGenerationId: '' } : null;
23
+ }
24
+ function checkpointCursor(checkpoint) {
25
+ return JSON.stringify(checkpoint);
26
+ }
27
+ function sourceGenerationId(filePath, firstLine, file) {
28
+ // This generation only changes on a detected rotation/reset. Appending to
29
+ // the same JSONL file retains the checkpoint's existing generation.
30
+ return `codex-jsonl-${(0, canonical_1.sha256)({ filePath, firstLineHash: firstLine ? (0, canonical_1.sha256)(firstLine) : null, inode: file.ino, mtimeMs: file.mtimeMs })}`;
31
+ }
32
+ /**
33
+ * Imports only unseen Codex JSONL lines. A file truncation/reset is treated as
34
+ * a new source generation rather than silently skipping fresh evidence.
35
+ */
36
+ async function importCodexJsonl(outbox, filePath, context, captureContent = false) {
37
+ const scope = `codex-jsonl:${filePath}`;
38
+ const [storedCheckpoint, raw, file] = await Promise.all([outbox.getCheckpoint('codex', scope), (0, promises_1.readFile)(filePath, 'utf8'), (0, promises_1.stat)(filePath)]);
39
+ // JSONL writers can leave their final record half-written. Import only
40
+ // newline-terminated records and leave the partial tail for the next pass.
41
+ const split = raw.split(/\r?\n/);
42
+ const lines = split.slice(0, -1);
43
+ const checkpoint = parseCodexCheckpoint(storedCheckpoint?.cursor);
44
+ const completedLines = checkpoint?.completedLines || 0;
45
+ // A line-count cursor alone cannot distinguish a replacement of equal size.
46
+ // The durable hash of the last consumed line catches truncation, rotation and
47
+ // replacement without ever storing the source record itself.
48
+ const checkpointMatches = Boolean(checkpoint && completedLines <= lines.length && (!checkpoint.lastLineHash || completedLines === 0 || checkpoint.lastLineHash === (0, canonical_1.sha256)(lines[completedLines - 1])));
49
+ const rotated = Boolean(checkpoint && !checkpointMatches);
50
+ const start = rotated ? 0 : completedLines;
51
+ const generationId = checkpoint && !rotated && checkpoint.sourceGenerationId
52
+ ? checkpoint.sourceGenerationId
53
+ : sourceGenerationId(filePath, lines[0], file);
54
+ let imported = 0;
55
+ let skipped = 0;
56
+ let deferred = raw.length > 0 && !(raw.endsWith('\n') || raw.endsWith('\r\n')) ? 1 : 0;
57
+ let consumed = start;
58
+ for (let index = start; index < lines.length; index += 1) {
59
+ try {
60
+ const observation = (0, providers_1.fromCodexJsonl)(JSON.parse(lines[index]), (0, node_path_1.basename)(filePath), captureContent);
61
+ if (!observation) {
62
+ skipped += 1;
63
+ consumed = index + 1;
64
+ continue;
65
+ }
66
+ // Provider-provided generation identifiers win. For plain JSONL logs,
67
+ // the host-generated source generation makes a rotated file a distinct
68
+ // server session rather than a conflicting replay of old event IDs.
69
+ observation.sourceGenerationId ||= generationId;
70
+ const result = await outbox.enqueue(observation, context);
71
+ if (result.status === 'ENQUEUED')
72
+ imported += 1;
73
+ else
74
+ skipped += 1;
75
+ consumed = index + 1;
76
+ }
77
+ catch {
78
+ // Do not advance the checkpoint across a malformed complete line. It
79
+ // may be a writer flush race despite the newline; advancing would make
80
+ // fresh evidence disappear permanently. Later lines wait behind it so
81
+ // sequence ordering remains truthful.
82
+ deferred += lines.length - index;
83
+ break;
84
+ }
85
+ }
86
+ await outbox.checkpoint('codex', scope, checkpointCursor({ version: 2, completedLines: consumed, lastLineHash: consumed ? (0, canonical_1.sha256)(lines[consumed - 1]) : undefined, sourceGenerationId: generationId }));
87
+ return { imported, skipped, deferred, rotated };
88
+ }
89
+ async function flushIfConfigured(outbox, config) {
90
+ if (config)
91
+ await new transport_1.CaptureHttpTransport(outbox, config).flush();
92
+ }
@@ -0,0 +1,8 @@
1
+ export * from './fileOutbox';
2
+ export * from './types';
3
+ export * from './redaction';
4
+ export * from './providers';
5
+ export * from './transport';
6
+ export * from './host';
7
+ export * from './localHost';
8
+ export * from './service';
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./fileOutbox"), exports);
18
+ __exportStar(require("./types"), exports);
19
+ __exportStar(require("./redaction"), exports);
20
+ __exportStar(require("./providers"), exports);
21
+ __exportStar(require("./transport"), exports);
22
+ __exportStar(require("./host"), exports);
23
+ __exportStar(require("./localHost"), exports);
24
+ __exportStar(require("./service"), exports);
@@ -0,0 +1,81 @@
1
+ import type { CaptureRepositoryReference } from '@zewstid/zeye-shared';
2
+ declare const ZEYE_HOOK_EVENTS: readonly ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop", "PreCompact"];
3
+ export type ClaudeHookEvent = typeof ZEYE_HOOK_EVENTS[number];
4
+ export type RepositoryIdentity = {
5
+ repositoryRoot: string;
6
+ fullName?: string;
7
+ providerRepositoryId?: string;
8
+ branch?: string;
9
+ headCommitSha?: string;
10
+ remoteUrlHash?: string;
11
+ };
12
+ export type LocalHostConfig = {
13
+ version: 1;
14
+ claudeInstalls: Array<{
15
+ repositoryRoot: string;
16
+ settingsPath: string;
17
+ command: string;
18
+ }>;
19
+ codexSources: Array<{
20
+ repositoryRoot: string;
21
+ jsonlPath: string;
22
+ }>;
23
+ };
24
+ export declare function localHostConfigPath(environment?: NodeJS.ProcessEnv): string;
25
+ export declare function localOutboxDirectory(repositoryRoot: string, environment?: NodeJS.ProcessEnv): string;
26
+ export declare function ensurePrivateDirectory(directory: string): Promise<void>;
27
+ export declare function readLocalHostConfig(path?: string): Promise<LocalHostConfig>;
28
+ export declare function writeLocalHostConfig(config: LocalHostConfig, path?: string): Promise<void>;
29
+ export declare function discoverRepositoryIdentity(repositoryPath: string, options?: {
30
+ providerRepositoryId?: string;
31
+ repositoryId?: string;
32
+ }): Promise<RepositoryIdentity | null>;
33
+ export declare function captureRepositoryReference(identity: RepositoryIdentity | null, repositoryId?: string): CaptureRepositoryReference[];
34
+ export type CaptureLock = {
35
+ release(): Promise<void>;
36
+ };
37
+ export type CaptureLockOptions = {
38
+ maxWaitMs?: number;
39
+ initialRetryDelayMs?: number;
40
+ maxRetryDelayMs?: number;
41
+ };
42
+ export declare function captureLockStatus(directory: string): Promise<{
43
+ held: boolean;
44
+ pid?: number;
45
+ stale?: boolean;
46
+ }>;
47
+ export declare function acquireCaptureLock(directory: string, options?: CaptureLockOptions): Promise<CaptureLock>;
48
+ export declare function withCaptureLock<T>(directory: string, operation: () => Promise<T>, options?: CaptureLockOptions): Promise<T>;
49
+ export declare function installClaudeHooks(input: {
50
+ repositoryRoot: string;
51
+ settingsPath?: string;
52
+ command?: string;
53
+ configPath?: string;
54
+ }): Promise<{
55
+ settingsPath: string;
56
+ installedEvents: ClaudeHookEvent[];
57
+ }>;
58
+ export declare function uninstallClaudeHooks(input: {
59
+ repositoryRoot: string;
60
+ settingsPath?: string;
61
+ command?: string;
62
+ configPath?: string;
63
+ }): Promise<{
64
+ settingsPath: string;
65
+ removedEvents: ClaudeHookEvent[];
66
+ }>;
67
+ export declare function installCodexSource(input: {
68
+ repositoryRoot: string;
69
+ jsonlPath: string;
70
+ configPath?: string;
71
+ }): Promise<void>;
72
+ export declare function uninstallCodexSource(input: {
73
+ repositoryRoot: string;
74
+ jsonlPath?: string;
75
+ configPath?: string;
76
+ }): Promise<number>;
77
+ export declare function configuredSourcesForRepository(config: LocalHostConfig, repositoryRoot: string): {
78
+ repositoryRoot: string;
79
+ jsonlPath: string;
80
+ }[];
81
+ export {};