@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.
@@ -0,0 +1,441 @@
1
+ import { scrub } from '@oortmem/core';
2
+ /**
3
+ * XML-style blocks Claude Code writes into user rows on the harness's behalf:
4
+ * background-task reports, `!` shell mode, slash commands and their local
5
+ * output, and system reminders (which carry hook context). They are not the
6
+ * user's words and are stripped, tag and content, before a row becomes text.
7
+ */
8
+ export const HARNESS_TAGS = [
9
+ 'task-notification',
10
+ 'bash-input',
11
+ 'bash-stdout',
12
+ 'bash-stderr',
13
+ 'command-name',
14
+ 'command-message',
15
+ 'command-args',
16
+ 'local-command-stdout',
17
+ 'local-command-caveat',
18
+ 'system-reminder',
19
+ ];
20
+ /**
21
+ * Oort's own injected block (see `formatForInjection`). Stripped from user and
22
+ * assistant text alike, so recalled memories are never observed back into memory.
23
+ */
24
+ export const OORT_MEMORY_TAG = 'oort-memory';
25
+ /** Attributes on an opening tag are scanned at most this far, so a `<tag` with no `>` costs O(1). */
26
+ const MAX_OPEN_TAG_ATTRS = 256;
27
+ function tagSet(tags) {
28
+ return {
29
+ names: new Set(tags),
30
+ open: new RegExp(`<(${tags.join('|')})(?:\\s[^>]{0,${MAX_OPEN_TAG_ATTRS}})?>`, 'y'),
31
+ };
32
+ }
33
+ const USER_BLOCKS = tagSet([...HARNESS_TAGS, OORT_MEMORY_TAG]);
34
+ const ASSISTANT_BLOCKS = tagSet([OORT_MEMORY_TAG]);
35
+ /**
36
+ * Removes whole `<tag …>…</tag>` blocks for the tags in `set`: each block ends
37
+ * at the first matching close after it (non-greedy, across lines); an opening
38
+ * tag with no close is left as text. A linear scan: every tag's closing-tag
39
+ * search only moves forward, and a tag found to have no later close is never
40
+ * searched again, so unclosed tags cannot make it quadratic. Whitespace is
41
+ * tidied only when something was removed.
42
+ */
43
+ function stripBlocks(text, set) {
44
+ if (!text.includes('<'))
45
+ return text;
46
+ /** Per tag: the next close found (start, end), or null once none remains. */
47
+ const closes = new Map();
48
+ const findClose = (tag, from) => {
49
+ const cached = closes.get(tag);
50
+ if (cached === null)
51
+ return null;
52
+ if (cached !== undefined && cached.start >= from)
53
+ return cached;
54
+ const needle = `</${tag}`;
55
+ let p = from;
56
+ for (;;) {
57
+ const k = text.indexOf(needle, p);
58
+ if (k < 0) {
59
+ closes.set(tag, null);
60
+ return null;
61
+ }
62
+ let e = k + needle.length;
63
+ while (e < text.length && /\s/.test(text[e] ?? ''))
64
+ e++;
65
+ if (text[e] === '>') {
66
+ const found = { start: k, end: e + 1 };
67
+ closes.set(tag, found);
68
+ return found;
69
+ }
70
+ p = k + 1;
71
+ }
72
+ };
73
+ let out = '';
74
+ let i = 0;
75
+ let removed = false;
76
+ for (;;) {
77
+ const lt = text.indexOf('<', i);
78
+ if (lt < 0)
79
+ break;
80
+ set.open.lastIndex = lt;
81
+ const m = set.open.exec(text);
82
+ const tag = m?.[1];
83
+ if (m === null || tag === undefined || !set.names.has(tag)) {
84
+ out += text.slice(i, lt + 1);
85
+ i = lt + 1;
86
+ continue;
87
+ }
88
+ const afterOpen = lt + m[0].length;
89
+ const close = findClose(tag, afterOpen);
90
+ if (close === null) {
91
+ out += text.slice(i, afterOpen);
92
+ i = afterOpen;
93
+ continue;
94
+ }
95
+ out += text.slice(i, lt);
96
+ i = close.end;
97
+ removed = true;
98
+ }
99
+ if (!removed)
100
+ return text;
101
+ out += text.slice(i);
102
+ return out
103
+ .replace(/[ \t]+\n/g, '\n')
104
+ .replace(/\n{3,}/g, '\n\n')
105
+ .trim();
106
+ }
107
+ const TOOL_INPUT_CHARS = 120;
108
+ const TOOL_RESULT_CHARS = 200;
109
+ /**
110
+ * Scrubbing looks this far past the cut, so a secret that straddles the cut is
111
+ * seen whole (the longest secrets the scrubber knows, PEM keys, fit easily).
112
+ */
113
+ const SCRUB_LOOKAHEAD = 8192;
114
+ const MARKER = /\[REDACTED:[a-z0-9-]*\]/g;
115
+ /**
116
+ * Scrubs `s` before cutting it to `n` chars: cutting first would leave a
117
+ * secret's prefix that the scrubber no longer recognises. A cut that would
118
+ * fall inside a `[REDACTED:…]` marker moves to just before it.
119
+ */
120
+ export function scrubThenCut(s, n) {
121
+ const clean = scrub(s.length > n + SCRUB_LOOKAHEAD ? s.slice(0, n + SCRUB_LOOKAHEAD) : s).text;
122
+ if (clean.length <= n)
123
+ return clean;
124
+ let cut = n;
125
+ MARKER.lastIndex = 0;
126
+ for (let m = MARKER.exec(clean); m !== null && m.index < n; m = MARKER.exec(clean)) {
127
+ if (m.index + m[0].length > n)
128
+ cut = m.index;
129
+ }
130
+ return clean.slice(0, cut);
131
+ }
132
+ /**
133
+ * Cheap pre-filter: a message row always carries a top-level `"type":"user"` or
134
+ * `"type":"assistant"`. Lines without either (the bulky `api-request` rows, for
135
+ * one) are never handed to `JSON.parse`. Nested occurrences inside strings are
136
+ * escaped (`\"type\"`), so they never match.
137
+ */
138
+ const MESSAGE_ROW = /"type":\s*"(?:user|assistant)"/;
139
+ const str = (v) => (typeof v === 'string' ? v : undefined);
140
+ function blocksOf(content) {
141
+ if (typeof content === 'string')
142
+ return content;
143
+ if (!Array.isArray(content))
144
+ return undefined;
145
+ return content.filter((b) => typeof b === 'object' && b !== null);
146
+ }
147
+ function resultText(content) {
148
+ if (typeof content === 'string')
149
+ return content;
150
+ if (!Array.isArray(content))
151
+ return '';
152
+ const parts = [];
153
+ for (const b of content) {
154
+ if (typeof b === 'object' && b !== null && b['type'] === 'text') {
155
+ const t = str(b['text']);
156
+ if (t !== undefined)
157
+ parts.push(t);
158
+ }
159
+ }
160
+ return parts.join('\n');
161
+ }
162
+ /**
163
+ * Scrubs every string leaf of a tool input before it is serialised:
164
+ * `JSON.stringify` turns a newline into a literal `\n`, and a secret right
165
+ * after it would then follow a letter. Only what can reach the summary is
166
+ * scrubbed, so a huge input stays cheap: a leaf is cut to the remaining budget
167
+ * and leaves past it are emptied (serialising only lengthens text, so the
168
+ * budget still covers the summary and the scrub lookahead after it).
169
+ */
170
+ function scrubLeaves(v, budget) {
171
+ if (typeof v === 'string') {
172
+ if (budget.left <= 0)
173
+ return '';
174
+ const s = v.length > budget.left ? v.slice(0, budget.left) : v;
175
+ budget.left -= s.length;
176
+ return scrub(s).text;
177
+ }
178
+ if (Array.isArray(v))
179
+ return v.map((x) => scrubLeaves(x, budget));
180
+ if (typeof v === 'object' && v !== null) {
181
+ const out = {};
182
+ for (const [k, x] of Object.entries(v)) {
183
+ budget.left -= k.length;
184
+ Object.defineProperty(out, k, {
185
+ value: scrubLeaves(x, budget),
186
+ enumerable: true,
187
+ writable: true,
188
+ configurable: true,
189
+ });
190
+ }
191
+ return out;
192
+ }
193
+ return v;
194
+ }
195
+ function toolUseSummary(b) {
196
+ const name = str(b['name']) ?? 'tool';
197
+ let input;
198
+ try {
199
+ const budget = { left: TOOL_INPUT_CHARS + SCRUB_LOOKAHEAD };
200
+ input = JSON.stringify(scrubLeaves(b['input'] ?? {}, budget)) ?? '';
201
+ }
202
+ catch {
203
+ input = '';
204
+ }
205
+ return `${name}(${scrubThenCut(input, TOOL_INPUT_CHARS)})`;
206
+ }
207
+ function toolResultSummary(b) {
208
+ return `result: ${scrubThenCut(resultText(b['content']), TOOL_RESULT_CHARS)}`;
209
+ }
210
+ /** A row that is conversation: a user or assistant message on the main chain, not injected. */
211
+ function messageRow(line) {
212
+ if (!MESSAGE_ROW.test(line))
213
+ return undefined;
214
+ let r;
215
+ try {
216
+ r = JSON.parse(line);
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ if (typeof r !== 'object' || r === null || Array.isArray(r))
222
+ return undefined;
223
+ const row = r;
224
+ if (row['type'] !== 'user' && row['type'] !== 'assistant')
225
+ return undefined;
226
+ if (row['isSidechain'] === true)
227
+ return undefined;
228
+ // Injected rows: caveats, skill bodies, compaction summaries, synthetic API errors.
229
+ if (row['isMeta'] === true ||
230
+ row['isCompactSummary'] === true ||
231
+ row['isVisibleInTranscriptOnly'] === true ||
232
+ row['isApiErrorMessage'] === true) {
233
+ return undefined;
234
+ }
235
+ if (typeof row['uuid'] !== 'string')
236
+ return undefined;
237
+ const message = row['message'];
238
+ if (typeof message !== 'object' || message === null)
239
+ return undefined;
240
+ return row;
241
+ }
242
+ /**
243
+ * Parses a transcript into alternating user and assistant turns, keeping the
244
+ * uuids of the rows behind each turn. Rules:
245
+ * - only `user`/`assistant` rows; sidechain, meta and malformed rows are skipped;
246
+ * - consecutive assistant rows (Claude Code writes one row per content block)
247
+ * merge into one assistant turn; `thinking` blocks are ignored;
248
+ * - `tool_use` blocks become `"<name>(<first 120 chars of input>)"` summaries;
249
+ * - a user row holding only `tool_result` blocks merges into the previous
250
+ * turn's summaries as `"result: <first 200 chars>"`; mixed rows contribute
251
+ * their results to the previous turn and their text to a new user turn;
252
+ * - `HARNESS_TAGS` blocks and `<oort-memory>` are stripped from user text, and
253
+ * `<oort-memory>` from assistant text; a user row holding only harness output
254
+ * becomes a user turn with empty text, so the reply to it is a turn of its own.
255
+ */
256
+ export function parseTranscriptDetailed(jsonl) {
257
+ const out = [];
258
+ for (const line of jsonl.split('\n')) {
259
+ const row = messageRow(line);
260
+ if (row === undefined)
261
+ continue;
262
+ const role = row['type'];
263
+ const uuid = row['uuid'];
264
+ const content = blocksOf(row['message']['content']);
265
+ if (content === undefined)
266
+ continue;
267
+ const texts = [];
268
+ const summaries = [];
269
+ const results = [];
270
+ if (typeof content === 'string') {
271
+ texts.push(content);
272
+ }
273
+ else {
274
+ for (const b of content) {
275
+ const type = b['type'];
276
+ if (type === 'text') {
277
+ const t = str(b['text']);
278
+ if (t !== undefined)
279
+ texts.push(t);
280
+ }
281
+ else if (type === 'tool_use') {
282
+ summaries.push(toolUseSummary(b));
283
+ }
284
+ else if (type === 'tool_result') {
285
+ results.push(toolResultSummary(b));
286
+ }
287
+ }
288
+ }
289
+ const tags = role === 'user' ? USER_BLOCKS : ASSISTANT_BLOCKS;
290
+ const raw = texts.filter((t) => t.length > 0).join('\n');
291
+ const text = stripBlocks(raw, tags);
292
+ const prev = out.at(-1);
293
+ if (role === 'user') {
294
+ if (results.length > 0 && prev !== undefined)
295
+ prev.turn.toolSummaries.push(...results);
296
+ // A tool-result row (even one carrying a system reminder) stays part of the turn.
297
+ if (text.length === 0 && (raw.length === 0 || results.length > 0))
298
+ continue;
299
+ // A row of only harness output becomes an empty user turn: what Claude says
300
+ // next answers the harness, not the previous prompt.
301
+ out.push({
302
+ turn: {
303
+ uuid,
304
+ parentUuid: str(row['parentUuid']) ?? null,
305
+ role,
306
+ text,
307
+ toolSummaries: [],
308
+ timestamp: str(row['timestamp']) ?? '',
309
+ sessionId: str(row['sessionId']) ?? '',
310
+ cwd: str(row['cwd']) ?? '',
311
+ },
312
+ rowUuids: [uuid],
313
+ });
314
+ continue;
315
+ }
316
+ if (prev?.turn.role === 'assistant') {
317
+ const t = prev.turn;
318
+ if (text.length > 0)
319
+ t.text = t.text.length > 0 ? `${t.text}\n${text}` : text;
320
+ t.toolSummaries.push(...summaries);
321
+ t.uuid = uuid;
322
+ t.timestamp = str(row['timestamp']) ?? t.timestamp;
323
+ prev.rowUuids.push(uuid);
324
+ continue;
325
+ }
326
+ out.push({
327
+ turn: {
328
+ uuid,
329
+ parentUuid: str(row['parentUuid']) ?? null,
330
+ role,
331
+ text,
332
+ toolSummaries: summaries,
333
+ timestamp: str(row['timestamp']) ?? '',
334
+ sessionId: str(row['sessionId']) ?? '',
335
+ cwd: str(row['cwd']) ?? '',
336
+ },
337
+ rowUuids: [uuid],
338
+ });
339
+ }
340
+ return out;
341
+ }
342
+ export function parseTranscript(jsonl) {
343
+ return parseTranscriptDetailed(jsonl).map((d) => d.turn);
344
+ }
345
+ /**
346
+ * Per turn, its own timestamp when it parses, else the nearest turn's that
347
+ * does (earlier first on a tie), else `""` (observe then uses its own time).
348
+ * Linear: one pass each way.
349
+ */
350
+ function nearestKnownTimestamps(turns) {
351
+ const known = (t) => t !== undefined && t.timestamp !== '' && !Number.isNaN(Date.parse(t.timestamp));
352
+ const prev = [];
353
+ let last = -1;
354
+ turns.forEach((t, i) => {
355
+ if (known(t))
356
+ last = i;
357
+ prev.push(last);
358
+ });
359
+ const out = Array.from({ length: turns.length }, () => '');
360
+ let next = -1;
361
+ for (let i = turns.length - 1; i >= 0; i--) {
362
+ if (known(turns[i]))
363
+ next = i;
364
+ const p = prev[i];
365
+ const pick = p < 0 ? next : next < 0 ? p : i - p <= next - i ? p : next;
366
+ out[i] = pick < 0 ? '' : turns[pick].timestamp;
367
+ }
368
+ return out;
369
+ }
370
+ /**
371
+ * One observation per user→assistant pair, at the assistant turn's timestamp
372
+ * (or the nearest known one when it has none, see `nearestKnownTimestamps`).
373
+ * Consecutive user turns (an interrupted prompt and its retry) are joined; an
374
+ * assistant turn without a user turn before it, and a trailing unanswered user
375
+ * turn, yield nothing. A reply to harness-only rows (a background task report,
376
+ * say) is observed with `userText: ""`: no text is invented for the user. When
377
+ * both sides are empty after stripping, the pair yields nothing.
378
+ */
379
+ export function pairTurns(turns) {
380
+ const at = nearestKnownTimestamps(turns);
381
+ const out = [];
382
+ let pending = [];
383
+ turns.forEach((t, i) => {
384
+ if (t.role === 'user') {
385
+ pending.push(t);
386
+ return;
387
+ }
388
+ if (pending.length === 0)
389
+ return;
390
+ const userText = pending
391
+ .map((u) => u.text)
392
+ .filter((x) => x.length > 0)
393
+ .join('\n');
394
+ if (userText.length === 0 && t.text.length === 0) {
395
+ pending = [];
396
+ return;
397
+ }
398
+ out.push({
399
+ obs: {
400
+ userText,
401
+ assistantText: t.text,
402
+ toolSummaries: [...pending.flatMap((u) => u.toolSummaries), ...t.toolSummaries],
403
+ sessionId: t.sessionId,
404
+ turnRef: t.uuid,
405
+ at: at[i],
406
+ },
407
+ assistantIndex: i,
408
+ });
409
+ pending = [];
410
+ });
411
+ return out;
412
+ }
413
+ export function turnsToObservations(turns) {
414
+ return pairTurns(turns).map((p) => p.obs);
415
+ }
416
+ /**
417
+ * Observations not yet processed, given `cursor` (the uuid of the last
418
+ * processed assistant row). When the cursor sits inside a turn rather than at
419
+ * its end, the turn grew after it was processed (the transcript lagged, or a
420
+ * stop hook made Claude continue), so it is taken again.
421
+ */
422
+ export function selectAfterCursor(detailed, cursor) {
423
+ const paired = pairTurns(detailed.map((d) => d.turn));
424
+ if (cursor === null)
425
+ return { fresh: paired.map((p) => p.obs), cursorFound: true };
426
+ for (let i = detailed.length - 1; i >= 0; i--) {
427
+ const d = detailed[i];
428
+ if (d === undefined || d.turn.role !== 'assistant')
429
+ continue;
430
+ const at = d.rowUuids.lastIndexOf(cursor);
431
+ if (at < 0)
432
+ continue;
433
+ const from = at === d.rowUuids.length - 1 ? i + 1 : i;
434
+ return {
435
+ fresh: paired.filter((p) => p.assistantIndex >= from).map((p) => p.obs),
436
+ cursorFound: true,
437
+ };
438
+ }
439
+ return { fresh: paired.map((p) => p.obs), cursorFound: false };
440
+ }
441
+ //# sourceMappingURL=transcript.js.map
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@oortmem/claude-code",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Oort for Claude Code: hooks that recall and save memories, and transcript import.",
5
+ "keywords": [
6
+ "oort",
7
+ "agent-memory",
8
+ "ai-agents",
9
+ "llm-memory",
10
+ "claude-code",
11
+ "hooks",
12
+ "anthropic"
13
+ ],
14
+ "homepage": "https://oortcloud.dev",
15
+ "bugs": {
16
+ "url": "https://github.com/oortmem/oort/issues",
17
+ "email": "hello@oortcloud.dev"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/oortmem/oort.git",
22
+ "directory": "packages/claude-code"
23
+ },
24
+ "license": "Apache-2.0",
25
+ "author": "Oort <hello@oortcloud.dev>",
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=22.15.0"
29
+ },
30
+ "sideEffects": false,
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "!dist/**/*.map",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "@oortmem/core": "0.1.1",
50
+ "zod": "4.6.7"
51
+ },
52
+ "devDependencies": {
53
+ "@oortmem/store-sqlite": "0.1.1",
54
+ "vitest": "^5.0.5"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc -p tsconfig.json",
58
+ "typecheck": "tsc -p tsconfig.typecheck.json"
59
+ }
6
60
  }