@krishnadobhal/rewind-sdk-js 0.1.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "{}"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Krishna Dobhal
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,12 @@
1
+ import type { RedactConfig } from './redact.ts';
2
+ export type RewindConfig = {
3
+ /** Cassette store root. A directory; REWIND_SERVER switches the store instead. */
4
+ dir?: string;
5
+ redact?: RedactConfig;
6
+ };
7
+ /** Identity function giving config files type inference. */
8
+ export declare function defineConfig(config: RewindConfig): RewindConfig;
9
+ /** Store root used when nothing else says otherwise. */
10
+ export declare const DEFAULT_DIR = ".rewind";
11
+ /** Imports rewind.config.ts if the user wrote one. */
12
+ export declare function loadConfig(path?: string): Promise<RewindConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,30 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * One config surface: `rewind.config.ts`, overridden by the environment.
11
+ * Only what is read lives here — a knob nothing consumes is a promise the code breaks.
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ /** Identity function giving config files type inference. */
17
+ export function defineConfig(config) {
18
+ return config; // no validation yet, TypeScript is the check
19
+ }
20
+ /** Store root used when nothing else says otherwise. */
21
+ export const DEFAULT_DIR = '.rewind';
22
+ /** Imports rewind.config.ts if the user wrote one. */
23
+ export async function loadConfig(path) {
24
+ const file = resolve(path ?? 'rewind.config.ts'); // absolute, import() needs it
25
+ if (!existsSync(file))
26
+ return {}; // unconfigured is valid, not an error
27
+ // Node strips types on import, so a .ts config needs no build step.
28
+ const module = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
29
+ return module.default ?? {}; // a config with no default export is empty
30
+ }
@@ -0,0 +1,44 @@
1
+ import type { Cassette, Run, Step } from '@krishnadobhal/rewind-core/schema';
2
+ import type { Store } from './store.ts';
3
+ /** One queued write, in the shape the wire carries it. */
4
+ export type BatchWrite = {
5
+ kind: 'run';
6
+ run: Run;
7
+ } | {
8
+ kind: 'step';
9
+ step: Step;
10
+ } | {
11
+ kind: 'cassette';
12
+ cassette: Cassette;
13
+ };
14
+ /** A store that writes somewhere slow — Postgres, an ingest API, S3. */
15
+ export type AsyncStore = {
16
+ putRun(run: Run): Promise<void>;
17
+ appendStep(step: Step): Promise<void>;
18
+ putCassette(cassette: Cassette): Promise<void>;
19
+ putBatch?(writes: BatchWrite[]): Promise<void>;
20
+ };
21
+ export type EmitterOptions = {
22
+ target: AsyncStore;
23
+ /** Writes held before the newest are dropped. */
24
+ maxQueue?: number;
25
+ /** Writes sent in one call, when the target can take a batch. */
26
+ maxBatch?: number;
27
+ /** How long a write waits for company before the batch goes. */
28
+ batchMs?: number;
29
+ log?: (message: string, error: unknown) => void;
30
+ };
31
+ export type FlushResult = {
32
+ written: number;
33
+ dropped: number;
34
+ pending: number;
35
+ };
36
+ /** A Store that queues writes and drains them to a slow target. */
37
+ export declare function bufferedStore(options: EmitterOptions): Store & {
38
+ flush: () => Promise<FlushResult>;
39
+ stats: {
40
+ written: number;
41
+ dropped: number;
42
+ queued: number;
43
+ };
44
+ };
@@ -0,0 +1,99 @@
1
+ /** Sends one write through the target's individual methods. */
2
+ function sendOne(target, write) {
3
+ if (write.kind === 'run')
4
+ return target.putRun(write.run);
5
+ if (write.kind === 'step')
6
+ return target.appendStep(write.step);
7
+ return target.putCassette(write.cassette);
8
+ }
9
+ /** A Store that queues writes and drains them to a slow target. */
10
+ export function bufferedStore(options) {
11
+ const { target, maxQueue = 1000, maxBatch = 100, batchMs = 250 } = options;
12
+ const log = options.log ?? ((m, e) => console.warn(`[rewind] ${m}`, e));
13
+ const queue = [];
14
+ const stats = { written: 0, dropped: 0, queued: 0 };
15
+ let draining = null;
16
+ let timer = null;
17
+ let lastLoggedAt = 0;
18
+ /** Counts losses and says so at most once a minute. */
19
+ const drop = (count, why, error) => {
20
+ stats.dropped += count;
21
+ const now = Date.now();
22
+ if (now - lastLoggedAt < 60_000)
23
+ return;
24
+ lastLoggedAt = now;
25
+ log(`${why} (${stats.dropped} dropped)`, error);
26
+ };
27
+ /** Sends queued writes in order, batching when the target allows. */
28
+ const drain = async () => {
29
+ while (queue.length > 0) {
30
+ // Splice, not slice: a batch that fails is gone, not retried forever.
31
+ const batch = queue.splice(0, maxBatch);
32
+ stats.queued = queue.length;
33
+ if (target.putBatch) {
34
+ try {
35
+ await target.putBatch(batch);
36
+ stats.written += batch.length;
37
+ }
38
+ catch (error) {
39
+ // One request carried all of them, so all of them were lost (I1).
40
+ drop(batch.length, `${batch.length} writes failed`, error);
41
+ }
42
+ continue;
43
+ }
44
+ // No batch support: one at a time, in order, each guarded on its own. A single
45
+ // bad write must not take the rest of the batch with it — that is a property
46
+ // the local store had before batching existed.
47
+ for (const write of batch) {
48
+ try {
49
+ await sendOne(target, write);
50
+ stats.written++;
51
+ }
52
+ catch (error) {
53
+ drop(1, `${write.kind} write failed`, error);
54
+ }
55
+ }
56
+ }
57
+ };
58
+ /** Starts a drain if one is not already running. */
59
+ const schedule = () => {
60
+ if (timer !== null || draining !== null)
61
+ return;
62
+ // Steps arrive in bursts; a short wait lets a whole node's worth queue up.
63
+ timer = setTimeout(() => {
64
+ timer = null;
65
+ draining = drain().finally(() => {
66
+ draining = null;
67
+ if (queue.length > 0)
68
+ schedule(); // more arrived while we were away
69
+ });
70
+ }, batchMs);
71
+ timer.unref?.(); // never hold the process open on our account
72
+ };
73
+ /** Queues a write, or drops it if the queue is full. */
74
+ const push = (write) => {
75
+ if (queue.length >= maxQueue) {
76
+ // Drop the newest, not the oldest: the start of a run explains more than its end.
77
+ drop(1, `queue full at ${maxQueue}`, new Error('emitter backpressure'));
78
+ return;
79
+ }
80
+ queue.push(write);
81
+ stats.queued = queue.length;
82
+ schedule();
83
+ };
84
+ return {
85
+ stats,
86
+ putRun: (run) => push({ kind: 'run', run }),
87
+ appendStep: (step) => push({ kind: 'step', step }),
88
+ putCassette: (cassette) => push({ kind: 'cassette', cassette }),
89
+ async flush() {
90
+ if (timer !== null) {
91
+ clearTimeout(timer);
92
+ timer = null;
93
+ }
94
+ await draining; // let an in-flight drain finish before starting another
95
+ await drain();
96
+ return { written: stats.written, dropped: stats.dropped, pending: queue.length };
97
+ },
98
+ };
99
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Run } from '@krishnadobhal/rewind-core/schema';
2
+ import { Recorder } from './recorder.ts';
3
+ export declare const ENV_ENABLED = "REWIND_ENABLED";
4
+ export declare const ENV_DIR = "REWIND_DIR";
5
+ export declare const ENV_CONFIG = "REWIND_CONFIG";
6
+ export declare const ENV_SERVER = "REWIND_SERVER";
7
+ export declare const ENV_TOKEN = "REWIND_TOKEN";
8
+ export declare const ENV_FILE = "REWIND_ENV_FILE";
9
+ export declare function loadEnvFile(path?: string): void;
10
+ /** Returns this process's recorder, opening it once. */
11
+ export declare function recorderFromEnv(run?: Partial<Run>): Promise<Recorder | null>;
12
+ export declare function resetRecorder(): void;
package/dist/env.js ADDED
@@ -0,0 +1,78 @@
1
+ import { DEFAULT_DIR, loadConfig } from './config.js';
2
+ import { bufferedStore } from './emitter.js';
3
+ import { httpStore } from './http.js';
4
+ import { Recorder } from './recorder.js';
5
+ import { fileStore } from './store.js';
6
+ export const ENV_ENABLED = 'REWIND_ENABLED';
7
+ export const ENV_DIR = 'REWIND_DIR';
8
+ export const ENV_CONFIG = 'REWIND_CONFIG';
9
+ export const ENV_SERVER = 'REWIND_SERVER';
10
+ export const ENV_TOKEN = 'REWIND_TOKEN';
11
+ export const ENV_FILE = 'REWIND_ENV_FILE';
12
+ // Loads a .env into process.env, once per process.
13
+ let envFileLoaded = false;
14
+ export function loadEnvFile(path) {
15
+ if (envFileLoaded)
16
+ return;
17
+ envFileLoaded = true;
18
+ const file = path ?? process.env[ENV_FILE] ?? '.env';
19
+ if (file === '0')
20
+ return; // explicitly opted out
21
+ try {
22
+ process.loadEnvFile(file);
23
+ }
24
+ catch {
25
+ // No .env is the normal case for a library consumer, not an error.
26
+ }
27
+ }
28
+ /** Where the store lives for this process. */
29
+ async function storeRoot() {
30
+ const config = await loadConfig(process.env[ENV_CONFIG]); // may not exist
31
+ return process.env[ENV_DIR] ?? config.dir ?? DEFAULT_DIR; // flag beats config
32
+ }
33
+ let current = null;
34
+ /** Returns this process's recorder, opening it once. */
35
+ export async function recorderFromEnv(run) {
36
+ current ??= build(run); // first caller's metadata wins; later callers join the run
37
+ return current;
38
+ }
39
+ // Drops the memo so the next call opens a new run.
40
+ export function resetRecorder() {
41
+ current = null;
42
+ }
43
+ function chooseStore(root) {
44
+ const url = process.env[ENV_SERVER];
45
+ if (url === undefined || url === '')
46
+ return { store: fileStore(root) };
47
+ const buffered = bufferedStore({
48
+ target: httpStore({ url, token: process.env[ENV_TOKEN] }),
49
+ });
50
+ return { store: buffered, flush: buffered.flush };
51
+ }
52
+ /** Opens the run: reads config, picks a store, starts recording. */
53
+ async function build(run) {
54
+ loadEnvFile(); // before the check, so a .env can switch recording on
55
+ if (process.env[ENV_ENABLED] !== '1')
56
+ return null;
57
+ try {
58
+ const config = await loadConfig(process.env[ENV_CONFIG]); // may not exist
59
+ const { store, flush } = chooseStore(await storeRoot());
60
+ if (flush)
61
+ flushOnExit(flush);
62
+ return new Recorder({ store, redact: config.redact, run });
63
+ }
64
+ catch (error) {
65
+ // Fails open (I1): a broken config must not take the agent down.
66
+ console.warn('[rewind] recorder disabled:', error);
67
+ return null;
68
+ }
69
+ }
70
+ function flushOnExit(flush) {
71
+ let done = false;
72
+ process.on('beforeExit', () => {
73
+ if (done)
74
+ return; // beforeExit can fire more than once
75
+ done = true;
76
+ void flush().catch(() => { }); // failing to flush must not become an exception
77
+ });
78
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { AsyncStore } from './emitter.ts';
2
+ export type HttpStoreOptions = {
3
+ url: string;
4
+ token?: string;
5
+ timeoutMs?: number;
6
+ fetchImpl?: typeof fetch;
7
+ };
8
+ /** An AsyncStore that POSTs batches to an ingest server. */
9
+ export declare function httpStore(options: HttpStoreOptions): AsyncStore;
package/dist/http.js ADDED
@@ -0,0 +1,27 @@
1
+ /** An AsyncStore that POSTs batches to an ingest server. */
2
+ export function httpStore(options) {
3
+ const { url, token, timeoutMs = 10_000 } = options;
4
+ const send = options.fetchImpl ?? globalThis.fetch;
5
+ const endpoint = `${url.replace(/\/$/, '')}/v1/ingest`;
6
+ /** POSTs one batch, throwing on anything but success. */
7
+ const post = async (writes) => {
8
+ // A hung server would otherwise hold the queue open until the process ends.
9
+ const abort = AbortSignal.timeout(timeoutMs);
10
+ const response = await send(endpoint, {
11
+ method: 'POST',
12
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
13
+ body: JSON.stringify({ writes }),
14
+ signal: abort,
15
+ });
16
+ if (!response.ok)
17
+ throw new Error(`ingest ${response.status} ${response.statusText}`);
18
+ };
19
+ return {
20
+ // The batch path is the one that runs; these three exist so the store still
21
+ // satisfies AsyncStore for a caller that bypasses the emitter.
22
+ putRun: (run) => post([{ kind: 'run', run }]),
23
+ appendStep: (step) => post([{ kind: 'step', step }]),
24
+ putCassette: (cassette) => post([{ kind: 'cassette', cassette }]),
25
+ putBatch: post,
26
+ };
27
+ }
@@ -0,0 +1,26 @@
1
+ import type { Recorder } from './recorder.ts';
2
+ /** Anything with an invoke method — a model, a tool, a runnable. */
3
+ type Invokable = {
4
+ invoke: (input: never, config?: never) => unknown;
5
+ };
6
+ export type WithRewindOptions<M, T> = {
7
+ model?: M;
8
+ tools?: T[];
9
+ /** Supplied by tests; otherwise from REWIND_ENABLED and friends. */
10
+ recorder?: Recorder | null;
11
+ /**
12
+ * Turns a recorded response back into the object the graph expects.
13
+ * Kept for the reader that hands cassettes back to a graph; recording ignores it.
14
+ */
15
+ revive?: (response: unknown) => unknown;
16
+ };
17
+ export type Rewind<M, T> = {
18
+ model: M;
19
+ tools: T[];
20
+ recorder: Recorder | null;
21
+ /** Wraps one more model or tool — `model`/`tools` are sugar over this. */
22
+ wrap: <X extends Invokable>(target: X) => X;
23
+ };
24
+ /** Wraps a model and tools so every call is recorded. */
25
+ export declare function withRewind<M extends Invokable, T extends Invokable>(options?: WithRewindOptions<M, T>): Promise<Rewind<M, T>>;
26
+ export {};
@@ -0,0 +1,186 @@
1
+ import { recorderFromEnv } from './env.js';
2
+ /** Methods returning a new runnable that must stay wrapped. */
3
+ const REWRAPPING = new Set(['bindTools', 'bind', 'withConfig', 'withRetry']);
4
+ /** Wraps a model and tools so every call is recorded. */
5
+ export async function withRewind(options = {}) {
6
+ const recorder = options.recorder ?? (await recorderFromEnv());
7
+ const tools = options.tools ?? [];
8
+ const model = options.model;
9
+ const session = { recorder, revive: options.revive ?? ((r) => r) };
10
+ // Not recording: hand back the originals, so an un-instrumented run costs nothing.
11
+ if (recorder === null) {
12
+ return { model, tools, recorder: null, wrap: (target) => target };
13
+ }
14
+ const wrap = (target) => wrapAny(target, session);
15
+ return { model: model && wrap(model), tools: tools.map(wrap), recorder, wrap };
16
+ }
17
+ /** Dispatches to the model or tool wrapper by shape. */
18
+ function wrapAny(target, session) {
19
+ const t = unwrapModel(target); // a binding carries neither marker itself
20
+ // Models have _llmType/bindTools, tools have neither. Guessing wrong shows up as a
21
+ // `tool` step where a `model` step belongs.
22
+ const isModel = typeof t['_llmType'] === 'function' || typeof t['bindTools'] === 'function';
23
+ return isModel ? wrapModel(target, session) : wrapTool(target, session);
24
+ }
25
+ /** Proxies a chat model, turning each invoke into a step. */
26
+ function wrapModel(model, session, bound = []) {
27
+ return new Proxy(model, {
28
+ get(target, prop) {
29
+ const value = Reflect.get(target, prop, target); // target, not the proxy — getters recurse
30
+ if (prop === 'invoke') {
31
+ return (input, config) => observe(session, 'model', config, () => modelRequest(target, input, bound), () => value.call(target, input, config));
32
+ }
33
+ // bindTools returns a *new* runnable; unwrapped, the graph calls it directly.
34
+ if (REWRAPPING.has(prop) && typeof value === 'function') {
35
+ return (...args) => {
36
+ const next = value.apply(target, args);
37
+ const schemas = prop === 'bindTools' ? (providerTools(next) ?? args[0] ?? []) : bound;
38
+ return wrapModel(next, session, schemas); // schemas are part of the identity
39
+ };
40
+ }
41
+ return typeof value === 'function' ? value.bind(target) : value;
42
+ },
43
+ });
44
+ }
45
+ /** Proxies a tool, turning each invoke into a step. */
46
+ function wrapTool(tool, session) {
47
+ return new Proxy(tool, {
48
+ get(target, prop) {
49
+ const value = Reflect.get(target, prop, target);
50
+ if (prop !== 'invoke')
51
+ return typeof value === 'function' ? value.bind(target) : value;
52
+ const name = String(Reflect.get(target, 'name', target) ?? 'tool');
53
+ return (input, config) => observe(session, 'tool', config, () => ({ kind: 'tool', tool_name: name, args: toolArgs(input) }), // name + args, nothing else
54
+ () => value.call(target, input, config));
55
+ },
56
+ });
57
+ }
58
+ /** Answers a boundary crossing, and records what happened. */
59
+ async function observe(session, kind, config, request, call) {
60
+ const node = nodeName(config);
61
+ const { recorder } = session;
62
+ const started = Date.now(); // real clock; the virtual one arrives with B3
63
+ try {
64
+ const response = await call();
65
+ recorder?.record({
66
+ node,
67
+ kind,
68
+ request: request(),
69
+ response: plain(response),
70
+ latency_ms: Date.now() - started,
71
+ ...usage(response),
72
+ });
73
+ return response;
74
+ }
75
+ catch (error) {
76
+ // A provider error is a recorded outcome, not a gap (B9).
77
+ recorder?.record({
78
+ node,
79
+ kind,
80
+ request: request(),
81
+ response: null,
82
+ latency_ms: Date.now() - started,
83
+ error: { message: String(error) },
84
+ });
85
+ throw error; // the graph's error handling is the graph's business
86
+ }
87
+ }
88
+ /** Reads the LangGraph node name out of the config. */
89
+ function nodeName(config) {
90
+ const metadata = config?.metadata;
91
+ const fromGraph = metadata?.['langgraph_node']; // LangGraph sets this per node
92
+ if (typeof fromGraph === 'string')
93
+ return fromGraph;
94
+ const runName = config?.runName;
95
+ return runName ?? 'unknown'; // ponytail: a step with no node is still a step
96
+ }
97
+ /** Digs past Runnable bindings to the model underneath. */
98
+ function unwrapModel(model) {
99
+ let m = model;
100
+ // Identity read off a binding gives model_id "unknown" — every model looks alike.
101
+ for (let depth = 0; depth < 8 && m && typeof m['_llmType'] !== 'function'; depth++) {
102
+ const inner = m['bound'];
103
+ if (inner === undefined || inner === null)
104
+ break;
105
+ m = inner;
106
+ }
107
+ return m;
108
+ }
109
+ /** Builds the canonical model request from an invoke. */
110
+ function modelRequest(model, input, boundTools) {
111
+ const m = unwrapModel(model);
112
+ const llmType = m['_llmType']; // LangChain's provider discriminator
113
+ return {
114
+ kind: 'model',
115
+ provider: typeof llmType === 'function' ? String(llmType.call(m)) : 'unknown',
116
+ model_id: String(m['model'] ?? m['modelName'] ?? 'unknown'),
117
+ messages: messages(input),
118
+ tool_schemas: boundTools.map((tool, i) => toolSchema(tool, i)), // canonical() sorts these
119
+ ...pick(m, ['temperature', 'topP', 'topK', 'maxTokens']),
120
+ };
121
+ }
122
+ /** Flattens LangChain messages to plain role/content records. */
123
+ function messages(input) {
124
+ const list = Array.isArray(input) ? input : [input]; // a bare message is a list of one
125
+ return list.map((message) => {
126
+ const m = message;
127
+ const getType = m['_getType']; // BaseMessage hides its role behind this
128
+ return {
129
+ role: typeof getType === 'function' ? String(getType.call(message)) : String(m['role'] ?? 'user'),
130
+ content: m['content'] ?? null,
131
+ ...pick(m, ['tool_calls', 'tool_call_id', 'name', 'id']), // ids become ordinals later
132
+ };
133
+ });
134
+ }
135
+ /** Unwraps a tool call's args from its envelope. */
136
+ function toolArgs(input) {
137
+ const i = input;
138
+ // LangGraph passes the whole ToolCall; a direct caller passes bare args.
139
+ return i !== null && typeof i === 'object' && 'args' in i ? i['args'] : input;
140
+ }
141
+ /** Prefers the JSON-Schema tool dicts a binding carries. */
142
+ function providerTools(bound) {
143
+ // What the model actually sees, and plain JSON unlike a raw Zod object.
144
+ const b = bound;
145
+ for (const tools of [b?.kwargs?.tools, b?.config?.tools]) {
146
+ if (Array.isArray(tools))
147
+ return tools;
148
+ }
149
+ return null;
150
+ }
151
+ /** Reduces a tool object to its identity fields. */
152
+ function toolSchema(tool, index) {
153
+ const t = tool;
154
+ const fn = (t['function'] ?? t); // OpenAI nests, others are flat
155
+ return {
156
+ name: String(fn['name'] ?? `tool_${index}`),
157
+ description: fn['description'] ?? null,
158
+ // ponytail: a raw Zod object hashes as its internal _def, which a Zod upgrade
159
+ // invalidates. Models that convert on bindTools hit providerTools() instead.
160
+ schema: stable(fn['parameters'] ?? fn['input_schema'] ?? fn['schema'] ?? null),
161
+ };
162
+ }
163
+ /** Drops memoized fields that appear only after first use. */
164
+ function stable(schema) {
165
+ if (schema === null || typeof schema !== 'object')
166
+ return schema;
167
+ // Zod fills `_cached` lazily: identity must not depend on how often it was used.
168
+ return JSON.parse(JSON.stringify(schema, (key, value) => (key === '_cached' ? undefined : value)));
169
+ }
170
+ /** Copies the keys that exist and are not null. */
171
+ function pick(source, keys) {
172
+ const out = {};
173
+ for (const key of keys)
174
+ if (source[key] !== undefined && source[key] !== null)
175
+ out[key] = source[key];
176
+ return out;
177
+ }
178
+ /** Pulls token counts off a model response, if present. */
179
+ function usage(response) {
180
+ const meta = response?.usage_metadata;
181
+ return typeof meta?.total_tokens === 'number' ? { tokens: meta.total_tokens } : {};
182
+ }
183
+ /** Strips class identity so the response serializes as data. */
184
+ function plain(value) {
185
+ return value === undefined ? null : JSON.parse(JSON.stringify(value));
186
+ }
@@ -0,0 +1,20 @@
1
+ import type { Run } from '@krishnadobhal/rewind-core/schema';
2
+ import type { Observation, RecorderOptions } from './types/recorder.ts';
3
+ export type { Observation, RecorderOptions } from './types/recorder.ts';
4
+ export declare class Recorder {
5
+ #private;
6
+ readonly run: Run;
7
+ readonly stats: {
8
+ dropped: number;
9
+ steps: number;
10
+ };
11
+ /** Opens a run and writes its row immediately. */
12
+ constructor(options: RecorderOptions);
13
+ /**
14
+ * Record one boundary crossing. Never throws.
15
+ * hash → redact → write cassette → append step → roll up totals.
16
+ */
17
+ record(observation: Observation): void;
18
+ /** Close the run out. Never throws. */
19
+ finish(outcome: Run['outcome'], status?: Run['status']): void;
20
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Runs inside the user's production agent and fails open : a store outage, a full
3
+ * disk, a serialization bug — none of them throw into the graph. The run goes `partial`.
4
+ */
5
+ import { HASH_VERSION, reqHash } from '@krishnadobhal/rewind-core/hash';
6
+ import { redact } from './redact.js';
7
+ import { ulid } from './ulid.js';
8
+ const LOG_INTERVAL_MS = 60_000;
9
+ export class Recorder {
10
+ run;
11
+ stats = { dropped: 0, steps: 0 };
12
+ #store;
13
+ #redact;
14
+ #log;
15
+ #lastLoggedAt = 0;
16
+ #seq = 0;
17
+ /** Opens a run and writes its row immediately. */
18
+ constructor(options) {
19
+ this.#store = options.store;
20
+ this.#redact = options.redact ?? {};
21
+ this.#log = options.log ?? ((m, e) => console.warn(`[rewind] ${m}`, e));
22
+ this.run = {
23
+ run_id: ulid(),
24
+ thread_id: '',
25
+ graph_sha: '',
26
+ code_sha: '',
27
+ prompt_sha: '',
28
+ model_cfg: {},
29
+ seed: Math.floor(Math.random() * 2 ** 48), // under 2^53, Run.seed is a number
30
+ flags_snapshot: {},
31
+ hash_version: HASH_VERSION,
32
+ started_at: new Date().toISOString(),
33
+ ended_at: null,
34
+ status: 'complete',
35
+ outcome: null,
36
+ tokens: 0,
37
+ cost_usd: 0,
38
+ latency_ms: 0,
39
+ ...options.run, // caller overrides any default above
40
+ };
41
+ // Written before the first step, so a process that dies mid-run still leaves an
42
+ // inspectable trace instead of orphaned steps.
43
+ // Written up front so a crashed run still leaves a row behind.
44
+ this.#guard('putRun', () => this.#store.putRun(this.run));
45
+ }
46
+ /**
47
+ * Record one boundary crossing. Never throws.
48
+ * hash → redact → write cassette → append step → roll up totals.
49
+ */
50
+ record(observation) {
51
+ this.#guard('record', () => {
52
+ // Hashed before redaction: a digest of a secret is not the secret, and it keeps
53
+ // req_hash stable when the redaction config changes.
54
+ const hash = reqHash(observation.request);
55
+ const seq = this.#seq++; // claimed before any write can fail
56
+ // Request and response redact together, so a value in both gets one token.
57
+ const safe = redact({ request: observation.request, response: observation.response }, this.#redact);
58
+ const body = safe.value;
59
+ const cassette = {
60
+ hash,
61
+ hash_version: HASH_VERSION,
62
+ kind: observation.kind,
63
+ // Inline, not an s3:// ref — externalize when these outgrow a single file read.
64
+ request: JSON.stringify(body.request),
65
+ response: JSON.stringify(body.response),
66
+ chunks: null,
67
+ provider: observation.provider ?? null,
68
+ model_version: observation.model_version ?? null,
69
+ redaction_map: safe.map,
70
+ refcount: 1,
71
+ recorded_at: new Date().toISOString(),
72
+ };
73
+ this.#store.putCassette(cassette); // no-op if this hash exists already
74
+ const step = {
75
+ run_id: this.run.run_id,
76
+ // Arrival order until the deterministic scheduler owns `seq` (B6).
77
+ seq,
78
+ node: observation.node,
79
+ kind: observation.kind,
80
+ req_hash: hash,
81
+ cassette_ref: hash,
82
+ match_tier: 'recorded',
83
+ latency_ms: observation.latency_ms,
84
+ tokens: observation.tokens ?? null,
85
+ cost_usd: observation.cost_usd ?? null,
86
+ error: observation.error ?? null,
87
+ };
88
+ this.#store.appendStep(step);
89
+ // Rolled up here so finish() has totals without re-reading the steps.
90
+ this.stats.steps++;
91
+ this.run.tokens += observation.tokens ?? 0;
92
+ this.run.cost_usd += observation.cost_usd ?? 0;
93
+ this.run.latency_ms += observation.latency_ms;
94
+ });
95
+ }
96
+ /** Close the run out. Never throws. */
97
+ finish(outcome, status = 'complete') {
98
+ this.run.ended_at = new Date().toISOString();
99
+ this.run.outcome = outcome;
100
+ // An incomplete recording is never a replay source; the caller cannot override that.
101
+ this.run.status = this.stats.dropped > 0 ? 'partial' : status;
102
+ this.#guard('finish', () => this.#store.putRun(this.run));
103
+ }
104
+ /** The one place a store failure may land: count it, degrade the run, carry on. */
105
+ /** Runs fn, swallowing and counting any failure (I1). */
106
+ #guard(what, fn) {
107
+ try {
108
+ fn();
109
+ }
110
+ catch (error) {
111
+ this.stats.dropped++;
112
+ this.run.status = 'partial';
113
+ const now = Date.now(); // rate-limit the log, not the counter
114
+ if (now - this.#lastLoggedAt >= LOG_INTERVAL_MS) {
115
+ this.#lastLoggedAt = now;
116
+ this.#log(`${what} failed; run ${this.run.run_id} is partial (${this.stats.dropped} dropped)`, error);
117
+ }
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Runs in the user's process, before anything is written. There is no read-time path.
3
+ * `redaction_map` holds token → matcher name, never the original value.
4
+ */
5
+ import type { RedactConfig, RedactResult } from './types/redact.ts';
6
+ export type { RedactConfig, RedactResult } from './types/redact.ts';
7
+ /** Assemble the matcher list, then walk once. Returns a new tree; the input is untouched. */
8
+ /** Redacts a value, returning it with a token map. */
9
+ export declare function redact(value: unknown, config?: RedactConfig): RedactResult;
package/dist/redact.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Runs in the user's process, before anything is written. There is no read-time path.
3
+ * `redaction_map` holds token → matcher name, never the original value.
4
+ */
5
+ /** Issuer prefixes: Visa, Mastercard, Amex, Discover, UnionPay, JCB. */
6
+ const CARD_PREFIX = /^(?:4|5[1-5]|2[2-7]|3[47]|6(?:011|5)|62|35)/;
7
+ /**
8
+ * Is this digit run actually a card number?
9
+ *
10
+ * A 13–19 digit run is card-*shaped*; a PRNG seed or an order number is too. Luhn alone
11
+ * lets one in ten through, so the issuer prefix has to agree as well. Over-redacting
12
+ * corrupts data the agent needs while protecting nothing.
13
+ */
14
+ function card(digits) {
15
+ const only = digits.replace(/\D/g, '');
16
+ if (only.length < 13 || !CARD_PREFIX.test(only))
17
+ return false;
18
+ let sum = 0;
19
+ for (let i = 0; i < only.length; i++) {
20
+ // Double every second digit from the right; 10 or more folds to its digit sum.
21
+ let d = Number(only[only.length - 1 - i]);
22
+ if (i % 2 === 1)
23
+ d = d * 2 > 9 ? d * 2 - 9 : d * 2;
24
+ sum += d;
25
+ }
26
+ return sum % 10 === 0;
27
+ }
28
+ const PRESET = [
29
+ // Bounded per RFC 5321 (local part <= 64, each domain label <= 63) for speed, not
30
+ // correctness: an unbounded [\w.+-]+ backtracks across the whole string from every
31
+ // start position, so a long token in the same character class and no @ — base64,
32
+ // a JWT, a data URI — costs O(n^2). 20 KB of base64 took 324ms before, 5.6ms after.
33
+ ['email', /[\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63})+/g],
34
+ ['token', /\b(?:Bearer\s+[\w.\-~+/]+=*|(?:sk|pk|rk)[-_](?:live|test)?[-_]?[A-Za-z0-9]{12,}|xox[baprs]-[\w-]{10,}|AKIA[0-9A-Z]{16}|eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,})/g],
35
+ ['card', /\b(?:\d[ -]?){13,19}\b/g, card],
36
+ // Numbers are not all +1 555, so this takes grouped digits with an optional country
37
+ // code — covering UK `020 7946 0958` and most of Europe. The boundaries are the load
38
+ // -bearing part: without them it eats the digit groups inside a UUID, which corrupts
39
+ // ids the agent needs while protecting nothing.
40
+ ['phone', /(?<![\w-])(?:\+\d{1,3}[ .-]?)?(?:\(\d{2,4}\)|\d{2,4})[ .-]\d{3,4}[ .-]?\d{3,4}(?![\w-])/g],
41
+ ];
42
+ class Tokens {
43
+ map = {}; // token → matcher name, no plaintext
44
+ #seen = new Map(); // original → token, for repeat values
45
+ #counts = new Map(); // per-matcher counter, gives the suffix
46
+ /** Same original → same token, so redacted text keeps its structure. */
47
+ for(original, matcher) {
48
+ let token = this.#seen.get(original);
49
+ if (token === undefined) {
50
+ const n = this.#counts.get(matcher) ?? 0;
51
+ this.#counts.set(matcher, n + 1);
52
+ token = `[redacted:${matcher}:${n}]`;
53
+ this.#seen.set(original, token);
54
+ this.map[token] = matcher;
55
+ }
56
+ return token;
57
+ }
58
+ }
59
+ /** Runs every matcher over one string, in order. */
60
+ function redactString(input, matchers, tokens) {
61
+ let out = input; // rewritten once per matcher
62
+ for (const [name, pattern, accept] of matchers) {
63
+ // Fresh RegExp per call: a shared /g pattern carries lastIndex between strings.
64
+ out = out.replace(new RegExp(pattern.source, pattern.flags), (m) =>
65
+ // A refused match is left exactly as it was found.
66
+ accept && !accept(m) ? m : tokens.for(m, name));
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * Depth-first over the tree, building a dotted path as it descends. A configured
72
+ * `fields` path wins outright — the whole value goes, whatever shape it is — before
73
+ * any pattern gets a chance to run against it.
74
+ */
75
+ /** Recurses the value, redacting strings and named fields. */
76
+ function walk(value, path, ctx) {
77
+ if (ctx.fields.has(path)) { // whole value goes, whatever type it is
78
+ return value === undefined ? value : ctx.tokens.for(`${path}:${JSON.stringify(value)}`, 'field');
79
+ }
80
+ if (typeof value === 'string')
81
+ return redactString(value, ctx.matchers, ctx.tokens);
82
+ if (Array.isArray(value))
83
+ return value.map((v, i) => walk(v, `${path}[${i}]`, ctx));
84
+ if (value !== null && typeof value === 'object') {
85
+ const out = {};
86
+ for (const [k, v] of Object.entries(value)) {
87
+ out[k] = walk(v, path === '' ? k : `${path}.${k}`, ctx);
88
+ }
89
+ return out;
90
+ }
91
+ return value;
92
+ }
93
+ /** Assemble the matcher list, then walk once. Returns a new tree; the input is untouched. */
94
+ /** Redacts a value, returning it with a token map. */
95
+ export function redact(value, config = {}) {
96
+ const matchers = config.preset === 'none' ? [] : [...PRESET]; // copy, PRESET is shared
97
+ (config.custom ?? []).forEach((re, i) => matchers.push([`custom:${i}`, re]));
98
+ const tokens = new Tokens(); // one map per call, numbering restarts
99
+ const redacted = walk(value, '', { matchers, fields: new Set(config.fields ?? []), tokens });
100
+ return { value: redacted, map: tokens.map };
101
+ }
@@ -0,0 +1,19 @@
1
+ import type { Cassette, Run, Step } from '@krishnadobhal/rewind-core/schema';
2
+ export type Store = {
3
+ putRun(run: Run): void;
4
+ appendStep(step: Step): void;
5
+ putCassette(cassette: Cassette): void;
6
+ };
7
+ /** One recorded run: its metadata plus its ordered steps. */
8
+ export type Trace = {
9
+ run: Run;
10
+ steps: Step[];
11
+ };
12
+ /** Creates a Store backed by a local directory. */
13
+ export declare function fileStore(root: string): Store;
14
+ /** Reads one run and its steps back off disk. */
15
+ export declare function readTrace(root: string, runId: string): Trace | null;
16
+ /** Lists run ids, oldest first, ULIDs sort by time. */
17
+ export declare function listRuns(root: string): string[];
18
+ /** Reads one cassette by its content hash. */
19
+ export declare function readCassette(root: string, hash: string): Cassette | null;
package/dist/store.js ADDED
@@ -0,0 +1,57 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ /** Creates a Store backed by a local directory. */
4
+ export function fileStore(root) {
5
+ // Shared by putRun and putCassette, both whole-file writes.
6
+ const write = (path, body) => {
7
+ mkdirSync(dirname(path), { recursive: true }); // parents may not exist yet
8
+ writeFileSync(path, body);
9
+ };
10
+ return {
11
+ putRun(run) {
12
+ // Rewritten on finish, so the last write wins.
13
+ write(join(root, 'runs', run.run_id, 'run.json'), JSON.stringify(run, null, 2) + '\n');
14
+ },
15
+ appendStep(step) {
16
+ const path = join(root, 'runs', step.run_id, 'steps.jsonl');
17
+ mkdirSync(dirname(path), { recursive: true });
18
+ appendFileSync(path, JSON.stringify(step) + '\n'); // append-only, one step per line
19
+ },
20
+ putCassette(cassette) {
21
+ const path = join(root, 'cassettes', `${cassette.hash}.json`);
22
+ // Content-addressed and immutable: a corrected recording is a new
23
+ // cassette, never an overwrite of this one.
24
+ if (existsSync(path))
25
+ return;
26
+ write(path, JSON.stringify(cassette) + '\n');
27
+ },
28
+ };
29
+ }
30
+ function orNull(read) {
31
+ try {
32
+ return read();
33
+ }
34
+ catch {
35
+ return null; // unreadable, corrupt, or not the shape we expected
36
+ }
37
+ }
38
+ /** Reads one run and its steps back off disk. */
39
+ export function readTrace(root, runId) {
40
+ const dir = join(root, 'runs', runId);
41
+ return orNull(() => {
42
+ const run = JSON.parse(readFileSync(join(dir, 'run.json'), 'utf8'));
43
+ const jsonl = existsSync(join(dir, 'steps.jsonl')) ? readFileSync(join(dir, 'steps.jsonl'), 'utf8') : '';
44
+ // Trim first: a trailing newline would split into one empty line.
45
+ const steps = jsonl.trim() === '' ? [] : jsonl.trim().split('\n').map((line) => JSON.parse(line));
46
+ return { run, steps };
47
+ });
48
+ }
49
+ /** Lists run ids, oldest first, ULIDs sort by time. */
50
+ export function listRuns(root) {
51
+ // Not a directory is as good as empty: nothing has been recorded here.
52
+ return orNull(() => readdirSync(join(root, 'runs')).sort()) ?? [];
53
+ }
54
+ /** Reads one cassette by its content hash. */
55
+ export function readCassette(root, hash) {
56
+ return orNull(() => JSON.parse(readFileSync(join(root, 'cassettes', `${hash}.json`), 'utf8')));
57
+ }
@@ -0,0 +1,23 @@
1
+ import type { RewindRequest } from '@krishnadobhal/rewind-core/request';
2
+ import type { Run, StepKind } from '@krishnadobhal/rewind-core/schema';
3
+ import type { RedactConfig } from './redact.ts';
4
+ import type { Store } from './store.ts';
5
+ /** One boundary crossing, as the SDK observes it. */
6
+ export type Observation = {
7
+ node: string;
8
+ kind: StepKind;
9
+ request: RewindRequest;
10
+ response: unknown;
11
+ latency_ms: number;
12
+ tokens?: number;
13
+ cost_usd?: number;
14
+ error?: unknown;
15
+ provider?: string;
16
+ model_version?: string;
17
+ };
18
+ export type RecorderOptions = {
19
+ store: Store;
20
+ redact?: RedactConfig;
21
+ run?: Partial<Run>;
22
+ log?: (message: string, error: unknown) => void;
23
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ export type RedactConfig = {
2
+ preset?: 'default' | 'none';
3
+ custom?: RegExp[];
4
+ fields?: string[];
5
+ };
6
+ export type RedactResult = {
7
+ value: unknown;
8
+ map: Record<string, string>;
9
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { Cassette, Run, Step } from '@krishnadobhal/rewind-core/schema';
2
+ /** Two lifetimes: a run and its steps belong to one run, cassettes are shared. */
3
+ export type Store = {
4
+ putRun(run: Run): void;
5
+ appendStep(step: Step): void;
6
+ putCassette(cassette: Cassette): void;
7
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/ulid.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ULID: 48-bit millisecond timestamp + 80 bits of randomness, base32.
3
+ * Lexicographic order is creation order, which is why DATA_MODEL.md picks it over UUIDv4.
4
+ */
5
+ export declare function ulid(now?: number): string;
package/dist/ulid.js ADDED
@@ -0,0 +1,18 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /** Crockford base32 — no I, L, O or U, so ids survive being read aloud. */
3
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
4
+ /**
5
+ * ULID: 48-bit millisecond timestamp + 80 bits of randomness, base32.
6
+ * Lexicographic order is creation order, which is why DATA_MODEL.md picks it over UUIDv4.
7
+ */
8
+ export function ulid(now = Date.now()) {
9
+ // Least-significant symbol first, each one prepended, so the result reads big-endian.
10
+ let time = ''; // 10 symbols, most significant first
11
+ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32))
12
+ time = ALPHABET[t % 32] + time;
13
+ // 256 / 32 is exact, so a uniform byte gives a uniform symbol.
14
+ let rand = ''; // 16 symbols, 80 bits
15
+ for (const byte of randomBytes(16))
16
+ rand += ALPHABET[byte % 32];
17
+ return time + rand;
18
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@krishnadobhal/rewind-sdk-js",
3
+ "version": "0.1.0",
4
+ "description": "Record every boundary an LLM agent crosses: withRewind, the recorder, the redactor, and the stores.",
5
+ "license": "Apache-2.0",
6
+ "author": "Krishna Dobhal",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/krishnadobhal/Rewind.git",
10
+ "directory": "packages/sdk-js"
11
+ },
12
+ "homepage": "https://github.com/krishnadobhal/Rewind#readme",
13
+ "bugs": "https://github.com/krishnadobhal/Rewind/issues",
14
+ "keywords": [
15
+ "rewind",
16
+ "langgraph",
17
+ "langchain",
18
+ "agents",
19
+ "record",
20
+ "observability"
21
+ ],
22
+ "type": "module",
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "exports": {
27
+ "./recorder": "./dist/recorder.js",
28
+ "./redact": "./dist/redact.js",
29
+ "./store": "./dist/store.js",
30
+ "./config": "./dist/config.js",
31
+ "./env": "./dist/env.js",
32
+ "./ulid": "./dist/ulid.js",
33
+ "./middleware": "./dist/middleware.js",
34
+ "./emitter": "./dist/emitter.js",
35
+ "./http": "./dist/http.js"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@krishnadobhal/rewind-core": "0.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^24.13.3"
45
+ }
46
+ }