@oortmem/claude-code 0.0.0-stage → 0.1.1

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/dist/queue.js ADDED
@@ -0,0 +1,351 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { appendPrivateFile, ensurePrivateDir, observe, scrubObservation, } from '@oortmem/core';
6
+ import { unlinkQuietly, withShortLock, writeFileAtomic } from './fsutil.js';
7
+ /**
8
+ * The write-path queue between the Stop hook and the drain.
9
+ *
10
+ * - `$OORT_HOME/queue/<session>.jsonl`: one JSON line per scrubbed observation,
11
+ * appended by the Stop hook (0600, directory 0700).
12
+ * - `$OORT_HOME/queue/.lock`: the drain lock (`wx`); one drain at a time. A lock
13
+ * older than ten minutes is from a crashed drain and is broken.
14
+ * - `<file>.lock`: a short per-file lock held by an append, and by the drain
15
+ * while it rewrites the file, so no concurrent append is lost.
16
+ * - `$OORT_HOME/queue/dead/<session>.jsonl`: lines that failed three times, or
17
+ * could not be parsed.
18
+ */
19
+ export const DRAIN_LOCK_STALE_MS = 10 * 60 * 1000;
20
+ export const DRAIN_MAX_PER_RUN = 50;
21
+ export const MAX_ATTEMPTS = 3;
22
+ /** Consecutive adapter failures after which a drain run stops (the adapter is likely down). */
23
+ const CIRCUIT_BREAKER = 3;
24
+ const APPEND_LOCK_WAIT_MS = 500;
25
+ const COMMIT_LOCK_WAIT_MS = 5_000;
26
+ const ScopeSchema = z.object({
27
+ orgId: z.string(),
28
+ customerId: z.string(),
29
+ agentId: z.string(),
30
+ projectId: z.string().optional(),
31
+ });
32
+ const ObservationSchema = z.object({
33
+ userText: z.string(),
34
+ assistantText: z.string(),
35
+ toolSummaries: z.array(z.string()),
36
+ sessionId: z.string().optional(),
37
+ turnRef: z.string().optional(),
38
+ projectId: z.string().optional(),
39
+ at: z.string(),
40
+ });
41
+ const QueueLineSchema = z.object({
42
+ v: z.literal(1),
43
+ id: z.string(),
44
+ scope: ScopeSchema,
45
+ obs: ObservationSchema,
46
+ attempts: z.number().int().min(0),
47
+ queuedAt: z.string(),
48
+ });
49
+ export const queueDir = (home) => path.join(home, 'queue');
50
+ /** A file-name-safe form of a session id; anything unusual is hashed. */
51
+ export function queueName(session) {
52
+ return /^[A-Za-z0-9_-]{1,128}$/.test(session)
53
+ ? session
54
+ : `h-${createHash('sha256').update(session).digest('hex').slice(0, 40)}`;
55
+ }
56
+ /**
57
+ * Scrubs and appends observations to the session's queue file; observations
58
+ * that are mostly secret are dropped. Never holds a lock across anything but
59
+ * the append; if the file lock is busy, the batch goes to a unique segment
60
+ * file instead (the drain reads every `*.jsonl`), so nothing is lost.
61
+ */
62
+ export function enqueueObservations(home, session, scope, observations, now = new Date()) {
63
+ const lines = [];
64
+ let dropped = 0;
65
+ for (const raw of observations) {
66
+ const s = scrubObservation(raw);
67
+ if (s.dropped) {
68
+ dropped++;
69
+ continue;
70
+ }
71
+ const line = {
72
+ v: 1,
73
+ id: randomUUID(),
74
+ scope,
75
+ obs: s.obs,
76
+ attempts: 0,
77
+ queuedAt: now.toISOString(),
78
+ };
79
+ lines.push(`${JSON.stringify(line)}\n`);
80
+ }
81
+ if (lines.length === 0)
82
+ return { queued: 0, dropped };
83
+ const dir = queueDir(home);
84
+ ensurePrivateDir(dir);
85
+ const file = path.join(dir, `${queueName(session)}.jsonl`);
86
+ const data = lines.join('');
87
+ const r = withShortLock(`${file}.lock`, APPEND_LOCK_WAIT_MS, () => appendPrivateFile(file, data));
88
+ if (!r.ok) {
89
+ const segment = path.join(dir, `${queueName(session)}.${process.pid}-${randomBytes(4).toString('hex')}.jsonl`);
90
+ appendPrivateFile(segment, data);
91
+ }
92
+ return { queued: lines.length, dropped };
93
+ }
94
+ function queueFiles(dir) {
95
+ let names;
96
+ try {
97
+ names = fs.readdirSync(dir);
98
+ }
99
+ catch {
100
+ return [];
101
+ }
102
+ const files = [];
103
+ for (const name of names) {
104
+ if (name.startsWith('.') || !name.endsWith('.jsonl'))
105
+ continue;
106
+ try {
107
+ const st = fs.statSync(path.join(dir, name));
108
+ if (st.isFile())
109
+ files.push({ name, mtime: st.mtimeMs });
110
+ }
111
+ catch {
112
+ // Removed by a concurrent drain.
113
+ }
114
+ }
115
+ files.sort((a, b) => a.mtime - b.mtime || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
116
+ return files.map((f) => f.name);
117
+ }
118
+ function countLines(dir) {
119
+ let n = 0;
120
+ for (const name of queueFiles(dir)) {
121
+ try {
122
+ const text = fs.readFileSync(path.join(dir, name), 'utf8');
123
+ for (const l of text.split('\n'))
124
+ if (l.length > 0)
125
+ n++;
126
+ }
127
+ catch {
128
+ // Gone.
129
+ }
130
+ }
131
+ return n;
132
+ }
133
+ /** True when some queue file is waiting to be drained. */
134
+ export function hasPendingQueue(home) {
135
+ return queueFiles(queueDir(home)).length > 0;
136
+ }
137
+ function parseLine(raw) {
138
+ try {
139
+ const r = QueueLineSchema.safeParse(JSON.parse(raw));
140
+ return r.success ? r.data : undefined;
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ /** Takes the drain lock, breaking one older than ten minutes; null when another drain holds it. */
147
+ function acquireDrainLock(lock) {
148
+ try {
149
+ return fs.openSync(lock, 'wx', 0o600);
150
+ }
151
+ catch (e) {
152
+ if (e.code !== 'EEXIST')
153
+ throw e;
154
+ }
155
+ try {
156
+ if (Date.now() - fs.statSync(lock).mtimeMs <= DRAIN_LOCK_STALE_MS)
157
+ return null;
158
+ unlinkQuietly(lock);
159
+ return fs.openSync(lock, 'wx', 0o600);
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ /**
166
+ * A stable id for a queued turn: `<sessionId>:<transcript turn>` when both are known, else the
167
+ * queue line's own id (kept across retries, since a failed line stays in its file).
168
+ */
169
+ export function turnRefOf(line) {
170
+ return line.obs.sessionId !== undefined && line.obs.turnRef !== undefined
171
+ ? `${line.obs.sessionId}:${line.obs.turnRef}`
172
+ : line.id;
173
+ }
174
+ /**
175
+ * Observes queued lines, oldest file first, at most 50 per run, under the
176
+ * drain lock. A line that observed is removed; one whose observe threw keeps
177
+ * its place with `attempts + 1`, and moves to `dead/` at the third failure.
178
+ * Without embed, decide and write adapters the queue is left untouched.
179
+ */
180
+ export async function drainQueue(deps) {
181
+ const t0 = performance.now();
182
+ const dir = queueDir(deps.config.home);
183
+ const logBase = { event: 'drain', session: null, project: null };
184
+ const { embed, decide, write, remote } = deps;
185
+ if (remote === undefined &&
186
+ (embed === undefined || decide === undefined || write === undefined)) {
187
+ const remaining = countLines(dir);
188
+ deps.log.write({
189
+ ...logBase,
190
+ outcome: 'drain-skipped:missing-adapters',
191
+ counts: { remaining },
192
+ timings: { totalMs: performance.now() - t0 },
193
+ });
194
+ return { processed: 0, failed: 0, remaining };
195
+ }
196
+ if (queueFiles(dir).length === 0) {
197
+ deps.log.write({
198
+ ...logBase,
199
+ outcome: 'ok',
200
+ counts: { processed: 0, failed: 0, remaining: 0 },
201
+ timings: { totalMs: performance.now() - t0 },
202
+ });
203
+ return { processed: 0, failed: 0, remaining: 0 };
204
+ }
205
+ const lockPath = path.join(dir, '.lock');
206
+ const lock = acquireDrainLock(lockPath);
207
+ if (lock === null) {
208
+ const remaining = countLines(dir);
209
+ deps.log.write({
210
+ ...logBase,
211
+ outcome: 'locked',
212
+ counts: { remaining },
213
+ timings: { totalMs: performance.now() - t0 },
214
+ });
215
+ return { processed: 0, failed: 0, remaining };
216
+ }
217
+ let processed = 0;
218
+ let failed = 0;
219
+ let budget = DRAIN_MAX_PER_RUN;
220
+ let consecutive = 0;
221
+ let deadCount = 0;
222
+ let held = false;
223
+ const deadIds = [];
224
+ try {
225
+ fs.writeSync(lock, `${process.pid}\n`);
226
+ for (const name of queueFiles(dir)) {
227
+ if (budget === 0 || consecutive >= CIRCUIT_BREAKER)
228
+ break;
229
+ const file = path.join(dir, name);
230
+ let buf;
231
+ try {
232
+ buf = fs.readFileSync(file);
233
+ }
234
+ catch {
235
+ continue;
236
+ }
237
+ // Only complete lines; a trailing partial line is left for later.
238
+ const end = buf.lastIndexOf(0x0a) + 1;
239
+ const lines = buf.subarray(0, end).toString('utf8').split('\n');
240
+ lines.pop();
241
+ const keep = [];
242
+ const dead = [];
243
+ for (const raw of lines) {
244
+ if (raw.length === 0)
245
+ continue;
246
+ if (budget === 0 || consecutive >= CIRCUIT_BREAKER) {
247
+ keep.push(raw);
248
+ continue;
249
+ }
250
+ const line = parseLine(raw);
251
+ if (line === undefined) {
252
+ dead.push(raw);
253
+ failed++;
254
+ continue;
255
+ }
256
+ budget--;
257
+ try {
258
+ if (remote !== undefined) {
259
+ await remote.observe(line.obs, line.scope.projectId, turnRefOf(line));
260
+ }
261
+ else {
262
+ await observe(line.obs, line.scope, {
263
+ store: deps.store,
264
+ embed: embed,
265
+ decide: decide,
266
+ write: write,
267
+ config: deps.config,
268
+ now: deps.now?.() ?? new Date(),
269
+ harness: 'claude-code',
270
+ });
271
+ }
272
+ processed++;
273
+ consecutive = 0;
274
+ }
275
+ catch (e) {
276
+ if (e?.holdQueue === true) {
277
+ // The account cannot save right now (signed out, no plan, a limit, an outage): keep
278
+ // this line and the rest exactly as they are and try again on a later drain.
279
+ held = true;
280
+ keep.push(raw);
281
+ budget = 0;
282
+ continue;
283
+ }
284
+ failed++;
285
+ consecutive++;
286
+ const next = JSON.stringify({ ...line, attempts: line.attempts + 1 });
287
+ if (line.attempts + 1 >= MAX_ATTEMPTS) {
288
+ dead.push(next);
289
+ deadIds.push(line.id);
290
+ }
291
+ else {
292
+ keep.push(next);
293
+ }
294
+ }
295
+ try {
296
+ const now = new Date();
297
+ fs.futimesSync(lock, now, now);
298
+ }
299
+ catch {
300
+ // Best effort: keeps a long run from looking stale.
301
+ }
302
+ }
303
+ if (dead.length > 0) {
304
+ deadCount += dead.length;
305
+ appendPrivateFile(path.join(dir, 'dead', name), dead.map((l) => `${l}\n`).join(''));
306
+ }
307
+ const committed = withShortLock(`${file}.lock`, COMMIT_LOCK_WAIT_MS, () => {
308
+ let current;
309
+ try {
310
+ current = fs.readFileSync(file);
311
+ }
312
+ catch {
313
+ current = Buffer.alloc(0);
314
+ }
315
+ // Appends only ever grow the file; everything past `end` arrived during this run.
316
+ const tail = current.subarray(Math.min(end, current.length));
317
+ const out = Buffer.concat([Buffer.from(keep.map((l) => `${l}\n`).join(''), 'utf8'), tail]);
318
+ if (out.length === 0)
319
+ unlinkQuietly(file);
320
+ else
321
+ writeFileAtomic(file, out, 0o600);
322
+ });
323
+ if (!committed.ok) {
324
+ // Lines stay queued and are observed again next run; a duplicate is reconciled.
325
+ deps.log.write({ ...logBase, outcome: 'commit-lock-busy', counts: {}, timings: {} });
326
+ }
327
+ }
328
+ }
329
+ finally {
330
+ fs.closeSync(lock);
331
+ unlinkQuietly(lockPath);
332
+ }
333
+ if (deadCount > 0) {
334
+ deps.log.write({
335
+ ...logBase,
336
+ outcome: 'dead-lettered',
337
+ counts: { lines: deadCount },
338
+ timings: {},
339
+ ids: deadIds,
340
+ });
341
+ }
342
+ const remaining = countLines(dir);
343
+ deps.log.write({
344
+ ...logBase,
345
+ outcome: held ? 'held' : 'ok',
346
+ counts: { processed, failed, remaining },
347
+ timings: { totalMs: performance.now() - t0 },
348
+ });
349
+ return { processed, failed, remaining };
350
+ }
351
+ //# sourceMappingURL=queue.js.map
@@ -0,0 +1,88 @@
1
+ import { type Observation } from '@oortmem/core';
2
+ /**
3
+ * Claude Code session transcripts (`~/.claude/projects/<project>/<session>.jsonl`).
4
+ * Only `user` and `assistant` rows carry conversation; every other row type
5
+ * (`attachment`, `system`, `api-request`, `file-history-snapshot`,
6
+ * `queue-operation`, titles, cost state, ...) is skipped.
7
+ */
8
+ export interface Turn {
9
+ /** The last row of the turn (for an assistant turn: its last assistant row). */
10
+ uuid: string;
11
+ /** The first row's parent. */
12
+ parentUuid: string | null;
13
+ role: 'user' | 'assistant';
14
+ text: string;
15
+ toolSummaries: string[];
16
+ /** The last row's timestamp. */
17
+ timestamp: string;
18
+ sessionId: string;
19
+ cwd: string;
20
+ }
21
+ /** A turn plus the uuids of every message row merged into it, in order. */
22
+ export interface DetailedTurn {
23
+ turn: Turn;
24
+ rowUuids: string[];
25
+ }
26
+ /**
27
+ * XML-style blocks Claude Code writes into user rows on the harness's behalf:
28
+ * background-task reports, `!` shell mode, slash commands and their local
29
+ * output, and system reminders (which carry hook context). They are not the
30
+ * user's words and are stripped, tag and content, before a row becomes text.
31
+ */
32
+ export declare const HARNESS_TAGS: readonly ['task-notification', 'bash-input', 'bash-stdout', 'bash-stderr', 'command-name', 'command-message', 'command-args', 'local-command-stdout', 'local-command-caveat', 'system-reminder'];
33
+ /**
34
+ * Oort's own injected block (see `formatForInjection`). Stripped from user and
35
+ * assistant text alike, so recalled memories are never observed back into memory.
36
+ */
37
+ export declare const OORT_MEMORY_TAG = "oort-memory";
38
+ /**
39
+ * Scrubs `s` before cutting it to `n` chars: cutting first would leave a
40
+ * secret's prefix that the scrubber no longer recognises. A cut that would
41
+ * fall inside a `[REDACTED:…]` marker moves to just before it.
42
+ */
43
+ export declare function scrubThenCut(s: string, n: number): string;
44
+ /**
45
+ * Parses a transcript into alternating user and assistant turns, keeping the
46
+ * uuids of the rows behind each turn. Rules:
47
+ * - only `user`/`assistant` rows; sidechain, meta and malformed rows are skipped;
48
+ * - consecutive assistant rows (Claude Code writes one row per content block)
49
+ * merge into one assistant turn; `thinking` blocks are ignored;
50
+ * - `tool_use` blocks become `"<name>(<first 120 chars of input>)"` summaries;
51
+ * - a user row holding only `tool_result` blocks merges into the previous
52
+ * turn's summaries as `"result: <first 200 chars>"`; mixed rows contribute
53
+ * their results to the previous turn and their text to a new user turn;
54
+ * - `HARNESS_TAGS` blocks and `<oort-memory>` are stripped from user text, and
55
+ * `<oort-memory>` from assistant text; a user row holding only harness output
56
+ * becomes a user turn with empty text, so the reply to it is a turn of its own.
57
+ */
58
+ export declare function parseTranscriptDetailed(jsonl: string): DetailedTurn[];
59
+ export declare function parseTranscript(jsonl: string): Turn[];
60
+ /** An observation and the index (into the turn list) of the assistant turn it came from. */
61
+ export interface PairedObservation {
62
+ obs: Observation;
63
+ assistantIndex: number;
64
+ }
65
+ /**
66
+ * One observation per user→assistant pair, at the assistant turn's timestamp
67
+ * (or the nearest known one when it has none, see `nearestKnownTimestamps`).
68
+ * Consecutive user turns (an interrupted prompt and its retry) are joined; an
69
+ * assistant turn without a user turn before it, and a trailing unanswered user
70
+ * turn, yield nothing. A reply to harness-only rows (a background task report,
71
+ * say) is observed with `userText: ""`: no text is invented for the user. When
72
+ * both sides are empty after stripping, the pair yields nothing.
73
+ */
74
+ export declare function pairTurns(turns: Turn[]): PairedObservation[];
75
+ export declare function turnsToObservations(turns: Turn[]): Observation[];
76
+ export interface CursorSelection {
77
+ /** Observations after the cursor, oldest first. */
78
+ fresh: Observation[];
79
+ /** False when a cursor was set but no row of this transcript carries it. */
80
+ cursorFound: boolean;
81
+ }
82
+ /**
83
+ * Observations not yet processed, given `cursor` (the uuid of the last
84
+ * processed assistant row). When the cursor sits inside a turn rather than at
85
+ * its end, the turn grew after it was processed (the transcript lagged, or a
86
+ * stop hook made Claude continue), so it is taken again.
87
+ */
88
+ export declare function selectAfterCursor(detailed: DetailedTurn[], cursor: string | null): CursorSelection;