@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/hooks.js ADDED
@@ -0,0 +1,321 @@
1
+ import * as fs from 'node:fs';
2
+ import { z } from 'zod';
3
+ import { activation, formatForInjection, localGrant, localScope, resolveProjectId, recall, shouldRecall, } from '@oortmem/core';
4
+ import { drainQueue, enqueueObservations, hasPendingQueue } from './queue.js';
5
+ import { parseTranscriptDetailed, selectAfterCursor } from './transcript.js';
6
+ /** Full recall gets this long before the hook falls back to lexical-only recall (CON-57: was 2.5 s). */
7
+ export const RECALL_TIMEOUT_MS = 1500;
8
+ /** At most this many observations are queued per Stop (the newest). */
9
+ export const STOP_MAX_OBSERVATIONS = 20;
10
+ /** Claude Code moves a longer `additionalContext` to a file and injects only a preview. */
11
+ const CONTEXT_MAX_CHARS = 10_000;
12
+ const EMPTY = { stdout: '', exitCode: 0 };
13
+ const TIMEOUT = Symbol('timeout');
14
+ export const cursorKey = (session) => `claude-code:${session}`;
15
+ function scopeFor(cwd, deps) {
16
+ return deps.scope ?? localScope(resolveProjectId(cwd, deps.config.home));
17
+ }
18
+ /** The scope's project plus global memories. A private project's memories carry its own id. */
19
+ export function filterFor(scope) {
20
+ return {
21
+ orgId: scope.orgId,
22
+ customerId: scope.customerId,
23
+ agentId: scope.agentId,
24
+ projectIds: [...new Set([scope.projectId ?? null, null])],
25
+ };
26
+ }
27
+ /** The grant for `filterFor(scope)`: what hooks, import and the CLI read and write. */
28
+ export function grantFor(scope) {
29
+ return localGrant(filterFor(scope));
30
+ }
31
+ function contextOutput(event, text) {
32
+ if (text.length === 0)
33
+ return EMPTY;
34
+ return {
35
+ stdout: JSON.stringify({
36
+ hookSpecificOutput: { hookEventName: event, additionalContext: text },
37
+ }),
38
+ exitCode: 0,
39
+ };
40
+ }
41
+ function withTimeout(p, ms) {
42
+ let timer;
43
+ const t = new Promise((resolve) => {
44
+ timer = setTimeout(() => resolve(TIMEOUT), ms);
45
+ });
46
+ return Promise.race([p, t]).finally(() => clearTimeout(timer));
47
+ }
48
+ /** Runs `body` with a log entry it fills in; the entry is written once, whatever happens. */
49
+ async function logged(base, deps, body) {
50
+ const t0 = performance.now();
51
+ const entry = { ...base, project: null, outcome: 'error', counts: {}, timings: {} };
52
+ try {
53
+ return await body(entry);
54
+ }
55
+ catch {
56
+ entry.outcome = 'error';
57
+ return EMPTY;
58
+ }
59
+ finally {
60
+ entry.timings['totalMs'] = performance.now() - t0;
61
+ deps.log.write(entry);
62
+ }
63
+ }
64
+ export async function handleUserPromptSubmit(input, deps) {
65
+ return logged({ event: 'UserPromptSubmit', session: input.session_id }, deps, async (entry) => {
66
+ const gate = shouldRecall(input.prompt);
67
+ if (!gate.recall) {
68
+ entry.outcome = `skipped:${gate.reason}`;
69
+ return EMPTY;
70
+ }
71
+ const scope = scopeFor(input.cwd, deps);
72
+ entry.project = scope.projectId ?? null;
73
+ const q = { text: input.prompt, scope: filterFor(scope) };
74
+ const now = deps.now?.() ?? new Date();
75
+ const { store, config } = deps;
76
+ const degraded = [];
77
+ const t = performance.now();
78
+ let result;
79
+ if (deps.remote !== undefined) {
80
+ // Oort Cloud: the cloud recalls. A timeout or an error injects nothing, never a local guess.
81
+ const remote = deps.remote.recall({
82
+ text: input.prompt,
83
+ ...(scope.projectId !== undefined && { projectId: scope.projectId }),
84
+ });
85
+ remote.catch(() => { });
86
+ let r;
87
+ try {
88
+ r = await withTimeout(remote, RECALL_TIMEOUT_MS);
89
+ }
90
+ catch {
91
+ r = undefined;
92
+ }
93
+ entry.timings['recallMs'] = performance.now() - t;
94
+ if (r === TIMEOUT || r === undefined) {
95
+ entry.degraded = [r === TIMEOUT ? 'cloud-timeout' : 'cloud-error'];
96
+ entry.counts = { memories: 0, chars: 0 };
97
+ entry.outcome = 'empty';
98
+ return EMPTY;
99
+ }
100
+ result = r;
101
+ }
102
+ else {
103
+ const full = recall(q, {
104
+ store,
105
+ config,
106
+ now,
107
+ ...(deps.embed !== undefined && { embed: deps.embed }),
108
+ ...(deps.rerank !== undefined && { rerank: deps.rerank }),
109
+ });
110
+ // The full recall may lose the race and fail later; nobody awaits it then.
111
+ full.catch(() => { });
112
+ try {
113
+ const r = await withTimeout(full, RECALL_TIMEOUT_MS);
114
+ if (r === TIMEOUT) {
115
+ degraded.push('timeout');
116
+ result = await recall(q, { store, config, now });
117
+ }
118
+ else {
119
+ result = r;
120
+ }
121
+ }
122
+ catch {
123
+ degraded.push('error');
124
+ result = await recall(q, { store, config, now });
125
+ }
126
+ entry.timings['recallMs'] = performance.now() - t;
127
+ }
128
+ // Per-stage timings from recall (numbers only), so slow stages show in the log (CON-57).
129
+ for (const [k, ms] of Object.entries(result.timings)) {
130
+ if (k !== 'totalMs' && k !== 'gateMs')
131
+ entry.timings[k] = ms;
132
+ }
133
+ degraded.push(...(result.degraded ?? []));
134
+ entry.degraded = [...new Set(degraded)];
135
+ const text = formatForInjection(result, Math.min(config.recall.budgetChars, CONTEXT_MAX_CHARS), now);
136
+ entry.counts = {
137
+ memories: result.memories.length,
138
+ chars: text.length,
139
+ ...(result.abstained === true && { abstained: 1 }),
140
+ ...(result.gate !== undefined && { gateDropped: result.gate.dropped }),
141
+ ...(result.rerankSkipped !== undefined && { rerankSkipped: 1 }),
142
+ ...(result.embedSkipped !== undefined && { embedSkipped: 1 }),
143
+ };
144
+ // Abstained: candidates were found but none was relevant enough, so nothing is injected.
145
+ entry.outcome =
146
+ text.length > 0 ? 'injected' : result.abstained === true ? 'abstained' : 'empty';
147
+ return contextOutput('UserPromptSubmit', text);
148
+ });
149
+ }
150
+ export async function handleStop(input, deps) {
151
+ return logged({ event: 'Stop', session: input.session_id }, deps, async (entry) => {
152
+ // Claude is already continuing because of a stop hook; this turn is not over.
153
+ if (input.stop_hook_active) {
154
+ entry.outcome = 'skipped:stop-hook-active';
155
+ return EMPTY;
156
+ }
157
+ const scope = scopeFor(input.cwd, deps);
158
+ entry.project = scope.projectId ?? null;
159
+ let text;
160
+ try {
161
+ text = fs.readFileSync(input.transcript_path, 'utf8');
162
+ }
163
+ catch {
164
+ entry.outcome = 'no-transcript';
165
+ return EMPTY;
166
+ }
167
+ const t = performance.now();
168
+ const detailed = parseTranscriptDetailed(text);
169
+ entry.timings['parseMs'] = performance.now() - t;
170
+ const key = cursorKey(input.session_id);
171
+ const grant = grantFor(scope);
172
+ const sel = selectAfterCursor(detailed, await deps.store.getCursor(grant, key));
173
+ let fresh = sel.fresh;
174
+ entry.counts = { turns: detailed.length, observations: fresh.length };
175
+ // Compaction or rotation dropped the cursor's row; take only the newest turns.
176
+ if (!sel.cursorFound)
177
+ entry.counts['cursorMissing'] = 1;
178
+ if (fresh.length > STOP_MAX_OBSERVATIONS) {
179
+ entry.counts['capped'] = fresh.length - STOP_MAX_OBSERVATIONS;
180
+ fresh = fresh.slice(-STOP_MAX_OBSERVATIONS);
181
+ }
182
+ const now = deps.now?.() ?? new Date();
183
+ const { queued, dropped } = enqueueObservations(deps.config.home, input.session_id, scope, fresh, now);
184
+ entry.counts['queued'] = queued;
185
+ entry.counts['dropped'] = dropped;
186
+ const last = fresh.at(-1)?.turnRef;
187
+ if (last !== undefined)
188
+ await deps.store.setCursor(grant, key, last);
189
+ if (queued > 0 || hasPendingQueue(deps.config.home)) {
190
+ try {
191
+ deps.spawnDrain?.();
192
+ }
193
+ catch {
194
+ entry.counts['spawnFailed'] = 1;
195
+ }
196
+ }
197
+ entry.outcome = queued > 0 ? 'queued' : 'nothing-new';
198
+ return EMPTY;
199
+ });
200
+ }
201
+ export async function handleSessionStart(input, deps) {
202
+ return logged({ event: 'SessionStart', session: input.session_id }, deps, async (entry) => {
203
+ const scope = scopeFor(input.cwd, deps);
204
+ entry.project = scope.projectId ?? null;
205
+ const now = deps.now?.() ?? new Date();
206
+ const { config } = deps;
207
+ let current;
208
+ if (deps.remote !== undefined) {
209
+ try {
210
+ current = await deps.remote.sessionMemories(scope.projectId !== undefined ? { projectId: scope.projectId } : {});
211
+ }
212
+ catch {
213
+ entry.degraded = ['cloud-error'];
214
+ entry.counts = { memories: 0, chars: 0 };
215
+ entry.outcome = 'empty';
216
+ return EMPTY;
217
+ }
218
+ }
219
+ else {
220
+ current = await deps.store.list(grantFor(scope));
221
+ }
222
+ const picked = current
223
+ .filter((m) => m.pinned || m.tier === 'hot')
224
+ .map((m) => ({
225
+ ...m,
226
+ explanation: {
227
+ fused: 0,
228
+ rrf: 0,
229
+ coverage: 0,
230
+ activation: activation(m.accessLog, m.salience, now, config.activation),
231
+ hydrated: false,
232
+ },
233
+ }))
234
+ .sort((a, b) => Number(b.pinned) - Number(a.pinned) ||
235
+ b.explanation.activation - a.explanation.activation);
236
+ const budget = Math.floor(Math.min(config.recall.budgetChars, CONTEXT_MAX_CHARS) / 2);
237
+ const text = formatForInjection({ memories: picked, timings: {} }, budget, now);
238
+ entry.counts = { memories: picked.length, chars: text.length };
239
+ entry.outcome = text.length > 0 ? 'injected' : 'empty';
240
+ return contextOutput('SessionStart', text);
241
+ });
242
+ }
243
+ const UserPromptSubmitInput = z.looseObject({
244
+ session_id: z.string(),
245
+ cwd: z.string(),
246
+ prompt: z.string(),
247
+ });
248
+ const StopInput = z.looseObject({
249
+ session_id: z.string(),
250
+ cwd: z.string(),
251
+ transcript_path: z.string(),
252
+ stop_hook_active: z.boolean().default(false),
253
+ });
254
+ const SessionStartInput = z.looseObject({
255
+ session_id: z.string(),
256
+ cwd: z.string(),
257
+ source: z.string().optional(),
258
+ });
259
+ /**
260
+ * The `oort hook <event>` entry point: parses the hook's stdin JSON (unknown
261
+ * fields are kept and ignored) and dispatches. `drain` ignores stdin. Invalid
262
+ * input logs one line and yields empty stdout; nothing here ever throws.
263
+ *
264
+ * Task 13 (CLI): write `stdout`, then call `process.exit(0)` at once; after a
265
+ * recall timeout the losing full recall is still pending and would hold the process.
266
+ */
267
+ export async function runHook(event, stdinText, deps) {
268
+ let session = null;
269
+ try {
270
+ if (event === 'drain') {
271
+ await drainQueue(deps);
272
+ return EMPTY;
273
+ }
274
+ let raw;
275
+ try {
276
+ raw = JSON.parse(stdinText);
277
+ }
278
+ catch {
279
+ raw = undefined;
280
+ }
281
+ if (typeof raw === 'object' && raw !== null && 'session_id' in raw) {
282
+ const s = raw.session_id;
283
+ if (typeof s === 'string')
284
+ session = s;
285
+ }
286
+ if (event === 'UserPromptSubmit') {
287
+ const p = UserPromptSubmitInput.safeParse(raw);
288
+ if (p.success)
289
+ return await handleUserPromptSubmit(p.data, deps);
290
+ }
291
+ else if (event === 'Stop') {
292
+ const p = StopInput.safeParse(raw);
293
+ if (p.success)
294
+ return await handleStop(p.data, deps);
295
+ }
296
+ else if (event === 'SessionStart') {
297
+ const p = SessionStartInput.safeParse(raw);
298
+ if (p.success)
299
+ return await handleSessionStart(p.data, deps);
300
+ }
301
+ deps.log.write({
302
+ event,
303
+ session,
304
+ project: null,
305
+ outcome: 'invalid-input',
306
+ counts: {},
307
+ timings: {},
308
+ });
309
+ return EMPTY;
310
+ }
311
+ catch {
312
+ try {
313
+ deps.log.write({ event, session, project: null, outcome: 'error', counts: {}, timings: {} });
314
+ }
315
+ catch {
316
+ // Nothing left to do; the session must go on.
317
+ }
318
+ return EMPTY;
319
+ }
320
+ }
321
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,71 @@
1
+ import { type ObserveResult } from '@oortmem/core';
2
+ import type { HookDeps } from './deps.js';
3
+ export interface ImportOptions {
4
+ /** Claude Code's projects directory, usually `~/.claude/projects`. */
5
+ projectsDir: string;
6
+ /** Only transcripts modified at or after this time. */
7
+ since?: Date;
8
+ /** Count and estimate only; write nothing. */
9
+ estimateOnly: boolean;
10
+ /** Share of observations expected to reach the writer (after the gate). Default 0.25. */
11
+ writerFraction?: number;
12
+ /**
13
+ * Import sessions whose project cannot be resolved into global scope.
14
+ * Default false: they are skipped, since a deleted private project's
15
+ * memories would otherwise surface in every project.
16
+ */
17
+ includeUnresolved?: boolean;
18
+ }
19
+ export interface ImportResult {
20
+ /** Sessions with at least one observation to import. */
21
+ sessions: number;
22
+ observations: number;
23
+ /** Writer tokens: Σ(chars / 4 + 400) × writerFraction. No price table in Phase 1. */
24
+ estimatedTokens: number;
25
+ /**
26
+ * Sessions with observations that were left out because their project could
27
+ * not be resolved (recorded `cwd` gone, or not in a git repository).
28
+ */
29
+ skippedUnresolved: number;
30
+ /** Transcripts that vanished or could not be read during the walk. */
31
+ skippedUnreadable: number;
32
+ results?: ObserveResult[];
33
+ /** Per-outcome counts, plus `failed` for observations whose observe threw. */
34
+ outcomes?: Record<string, number>;
35
+ /**
36
+ * Sessions that stopped at a failed observation: its cursor stays before it,
37
+ * and the session's later observations were not attempted (the Stop hook or
38
+ * a later import picks them up).
39
+ */
40
+ failedSessions?: Record<string, {
41
+ failed: number;
42
+ notAttempted: number;
43
+ reason: string;
44
+ }>;
45
+ /**
46
+ * Observations skipped because the failure is permanent for that input (the
47
+ * writer's output never fits the schema, or the model refused or truncated),
48
+ * keyed by `failureReason`. Their sessions continue and the cursor passes them.
49
+ */
50
+ unwritable?: Record<string, number>;
51
+ }
52
+ /**
53
+ * Why an observation failed, from the error's class and the typed fields our
54
+ * adapters set (`kind`, `status`, `fields`). Never the message: a provider's
55
+ * message may echo the observation it was given.
56
+ */
57
+ export declare function failureReason(e: unknown): string;
58
+ /**
59
+ * Imports existing Claude Code transcripts. Every transcript is parsed,
60
+ * paired and scrubbed; turns already behind a session's cursor (observed by
61
+ * the Stop hook or an earlier import) are skipped. With `estimateOnly` it
62
+ * only reports the estimate. Otherwise it observes oldest first across all
63
+ * sessions, advances each session's cursor so the Stop hook never re-observes
64
+ * imported turns, and appends one eval row per added memory to
65
+ * `$OORT_HOME/eval/import-<YYYY-MM-DD>.jsonl`.
66
+ *
67
+ * The scope of each transcript is derived from the `cwd` its rows recorded.
68
+ * A session whose project cannot be resolved is skipped (and counted in
69
+ * `skippedUnresolved`) unless `includeUnresolved` imports it as global.
70
+ */
71
+ export declare function importTranscripts(opts: ImportOptions, deps: HookDeps): Promise<ImportResult>;
package/dist/import.js ADDED
@@ -0,0 +1,244 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { appendPrivateFile, localScope, observe, resolveProjectId, scrubObservation, } from '@oortmem/core';
4
+ import { cursorKey, grantFor } from './hooks.js';
5
+ import { parseTranscriptDetailed, selectAfterCursor } from './transcript.js';
6
+ /** A failure a re-run would repeat for the same observation: skip it rather than stop the session. */
7
+ function isPermanent(e) {
8
+ if (!(e instanceof Error))
9
+ return false;
10
+ if (e.name === 'WriterOutputError')
11
+ return true;
12
+ return e.name === 'WriterError' && e.kind === 'bad-response';
13
+ }
14
+ /**
15
+ * Why an observation failed, from the error's class and the typed fields our
16
+ * adapters set (`kind`, `status`, `fields`). Never the message: a provider's
17
+ * message may echo the observation it was given.
18
+ */
19
+ export function failureReason(e) {
20
+ if (!(e instanceof Error))
21
+ return 'non-error thrown';
22
+ const { kind, status, fields } = e;
23
+ const parts = [];
24
+ if (typeof kind === 'string')
25
+ parts.push(kind);
26
+ if (typeof status === 'number')
27
+ parts.push(`HTTP ${status}`);
28
+ if (Array.isArray(fields) && fields.every((f) => typeof f === 'string'))
29
+ parts.push(`fields: ${fields.join(', ')}`);
30
+ return parts.length === 0 ? e.name : `${e.name} (${parts.join(', ')})`;
31
+ }
32
+ /** Fixed per-call writer overhead (system prompt, schema), in tokens. */
33
+ const WRITER_OVERHEAD_TOKENS = 400;
34
+ const DEFAULT_WRITER_FRACTION = 0.25;
35
+ /**
36
+ * Session transcripts: `<projectsDir>/<project>/<session>.jsonl`. Subagent
37
+ * transcripts (one level deeper) and orphaned copies Claude Code set aside
38
+ * (`<session>.orphaned-…jsonl`) are not sessions of their own.
39
+ */
40
+ function transcripts(projectsDir, since, skip) {
41
+ const out = [];
42
+ let projects;
43
+ try {
44
+ projects = fs.readdirSync(projectsDir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return out;
48
+ }
49
+ for (const p of projects) {
50
+ if (!p.isDirectory())
51
+ continue;
52
+ const dir = path.join(projectsDir, p.name);
53
+ let files;
54
+ try {
55
+ files = fs.readdirSync(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ for (const f of files) {
61
+ if (!f.isFile() || !f.name.endsWith('.jsonl') || f.name.includes('.orphaned-'))
62
+ continue;
63
+ const file = path.join(dir, f.name);
64
+ if (since !== undefined) {
65
+ let mtime;
66
+ try {
67
+ mtime = fs.statSync(file).mtimeMs;
68
+ }
69
+ catch {
70
+ skip(); // Vanished mid-walk.
71
+ continue;
72
+ }
73
+ if (mtime < since.getTime())
74
+ continue;
75
+ }
76
+ out.push(file);
77
+ }
78
+ }
79
+ return out.sort();
80
+ }
81
+ /**
82
+ * The session's project id, or undefined when it cannot be resolved. A missing
83
+ * directory is unresolved even inside a git repo: walking up from it would
84
+ * attribute the session to the enclosing repository instead.
85
+ */
86
+ function resolveProject(cwd, home) {
87
+ if (cwd === undefined || !fs.existsSync(cwd))
88
+ return undefined;
89
+ return resolveProjectId(cwd, home);
90
+ }
91
+ function chars(o) {
92
+ return (o.userText.length + o.assistantText.length + o.toolSummaries.reduce((n, s) => n + s.length, 0));
93
+ }
94
+ /**
95
+ * Imports existing Claude Code transcripts. Every transcript is parsed,
96
+ * paired and scrubbed; turns already behind a session's cursor (observed by
97
+ * the Stop hook or an earlier import) are skipped. With `estimateOnly` it
98
+ * only reports the estimate. Otherwise it observes oldest first across all
99
+ * sessions, advances each session's cursor so the Stop hook never re-observes
100
+ * imported turns, and appends one eval row per added memory to
101
+ * `$OORT_HOME/eval/import-<YYYY-MM-DD>.jsonl`.
102
+ *
103
+ * The scope of each transcript is derived from the `cwd` its rows recorded.
104
+ * A session whose project cannot be resolved is skipped (and counted in
105
+ * `skippedUnresolved`) unless `includeUnresolved` imports it as global.
106
+ */
107
+ export async function importTranscripts(opts, deps) {
108
+ const items = [];
109
+ let weighted = 0;
110
+ const sessions = new Set();
111
+ let skippedUnresolved = 0;
112
+ let skippedUnreadable = 0;
113
+ const skip = () => {
114
+ skippedUnreadable++;
115
+ };
116
+ for (const file of transcripts(opts.projectsDir, opts.since, skip)) {
117
+ const session = path.basename(file, '.jsonl');
118
+ let text;
119
+ try {
120
+ text = fs.readFileSync(file, 'utf8');
121
+ }
122
+ catch {
123
+ skip();
124
+ continue;
125
+ }
126
+ const detailed = parseTranscriptDetailed(text);
127
+ const cwd = detailed.find((d) => d.turn.cwd.length > 0)?.turn.cwd;
128
+ const projectId = resolveProject(cwd, deps.config.home);
129
+ const scope = localScope(projectId);
130
+ const { fresh } = selectAfterCursor(detailed, await deps.store.getCursor(grantFor(scope), cursorKey(session)));
131
+ const mine = [];
132
+ for (const raw of fresh) {
133
+ const s = scrubObservation(raw);
134
+ if (s.dropped)
135
+ continue;
136
+ mine.push({ session, pos: mine.length, obs: s.obs, scope });
137
+ }
138
+ if (mine.length === 0)
139
+ continue;
140
+ if (projectId === undefined && opts.includeUnresolved !== true) {
141
+ skippedUnresolved++;
142
+ continue;
143
+ }
144
+ for (const item of mine)
145
+ weighted += chars(item.obs) / 4 + WRITER_OVERHEAD_TOKENS;
146
+ items.push(...mine);
147
+ sessions.add(session);
148
+ }
149
+ const fraction = opts.writerFraction ?? DEFAULT_WRITER_FRACTION;
150
+ const summary = {
151
+ sessions: sessions.size,
152
+ observations: items.length,
153
+ estimatedTokens: Math.ceil(weighted * fraction),
154
+ skippedUnresolved,
155
+ skippedUnreadable,
156
+ };
157
+ if (opts.estimateOnly)
158
+ return summary;
159
+ const { embed, decide, write } = deps;
160
+ if (embed === undefined || decide === undefined || write === undefined) {
161
+ throw new Error('import needs the embed, decide and write adapters');
162
+ }
163
+ // Oldest first across sessions, but never out of order within one: each item
164
+ // sorts at its session's running maximum timestamp (the sort is stable).
165
+ const runningMax = new Map();
166
+ const key = new Map();
167
+ for (const item of items) {
168
+ const t = Date.parse(item.obs.at);
169
+ const k = Math.max(runningMax.get(item.session) ?? 0, Number.isNaN(t) ? 0 : t);
170
+ runningMax.set(item.session, k);
171
+ key.set(item, k);
172
+ }
173
+ items.sort((a, b) => (key.get(a) ?? 0) - (key.get(b) ?? 0));
174
+ const day = (deps.now?.() ?? new Date()).toISOString().slice(0, 10);
175
+ const evalFile = path.join(deps.config.home, 'eval', `import-${day}.jsonl`);
176
+ const results = [];
177
+ const outcomes = {};
178
+ const failedSessions = {};
179
+ const unwritable = {};
180
+ const done = new Map();
181
+ // Only forward within a session, and only past observations that are finished with.
182
+ const advance = async (item) => {
183
+ const prev = done.get(item.session) ?? -1;
184
+ if (item.pos > prev && item.obs.turnRef !== undefined) {
185
+ await deps.store.setCursor(grantFor(item.scope), cursorKey(item.session), item.obs.turnRef);
186
+ done.set(item.session, item.pos);
187
+ }
188
+ };
189
+ for (const item of items) {
190
+ const stopped = failedSessions[item.session];
191
+ if (stopped !== undefined) {
192
+ // The session stopped at an earlier failure; its cursor must not pass it.
193
+ stopped.notAttempted++;
194
+ continue;
195
+ }
196
+ let r;
197
+ try {
198
+ r = await observe(item.obs, item.scope, {
199
+ store: deps.store,
200
+ embed,
201
+ decide,
202
+ write,
203
+ config: deps.config,
204
+ now: deps.now?.() ?? new Date(),
205
+ harness: 'claude-code',
206
+ });
207
+ }
208
+ catch (e) {
209
+ if (isPermanent(e)) {
210
+ outcomes['unwritable'] = (outcomes['unwritable'] ?? 0) + 1;
211
+ const why = failureReason(e);
212
+ unwritable[why] = (unwritable[why] ?? 0) + 1;
213
+ await advance(item);
214
+ continue;
215
+ }
216
+ outcomes['failed'] = (outcomes['failed'] ?? 0) + 1;
217
+ failedSessions[item.session] = { failed: 1, notAttempted: 0, reason: failureReason(e) };
218
+ continue;
219
+ }
220
+ results.push(r);
221
+ // A turn can become several memories (CON-56): count and log each one.
222
+ for (const m of r.memories) {
223
+ outcomes[m.outcome] = (outcomes[m.outcome] ?? 0) + 1;
224
+ if (m.outcome !== 'added' || m.memoryId === undefined)
225
+ continue;
226
+ const row = {
227
+ memoryId: m.memoryId,
228
+ question: '',
229
+ expectedMemoryIds: [m.memoryId],
230
+ asOf: item.obs.at,
231
+ };
232
+ appendPrivateFile(evalFile, `${JSON.stringify(row)}\n`);
233
+ }
234
+ await advance(item);
235
+ }
236
+ return {
237
+ ...summary,
238
+ results,
239
+ outcomes,
240
+ ...(Object.keys(failedSessions).length > 0 && { failedSessions }),
241
+ ...(Object.keys(unwritable).length > 0 && { unwritable }),
242
+ };
243
+ }
244
+ //# sourceMappingURL=import.js.map
@@ -0,0 +1,7 @@
1
+ export type { HookDeps, RemoteMemory } from './deps.js';
2
+ export { createHookLogger, HOOK_LOG_MAX_BYTES, type HookLogEntry, type HookLogger } from './log.js';
3
+ export { HARNESS_TAGS, OORT_MEMORY_TAG, parseTranscript, parseTranscriptDetailed, selectAfterCursor, turnsToObservations, type DetailedTurn, type Turn, } from './transcript.js';
4
+ export { cursorKey, filterFor, grantFor, handleSessionStart, handleStop, handleUserPromptSubmit, RECALL_TIMEOUT_MS, runHook, STOP_MAX_OBSERVATIONS, type HookEvent, type HookOutput, } from './hooks.js';
5
+ export { drainQueue, enqueueObservations, queueDir, type DrainResult, type QueueLine, } from './queue.js';
6
+ export { HOOK_EVENTS, HOOK_TIMEOUTS, hookStatus, installHooks, uninstallHooks, type HookEventStatus, } from './install.js';
7
+ export { importTranscripts, type ImportOptions, type ImportResult } from './import.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createHookLogger, HOOK_LOG_MAX_BYTES } from './log.js';
2
+ export { HARNESS_TAGS, OORT_MEMORY_TAG, parseTranscript, parseTranscriptDetailed, selectAfterCursor, turnsToObservations, } from './transcript.js';
3
+ export { cursorKey, filterFor, grantFor, handleSessionStart, handleStop, handleUserPromptSubmit, RECALL_TIMEOUT_MS, runHook, STOP_MAX_OBSERVATIONS, } from './hooks.js';
4
+ export { drainQueue, enqueueObservations, queueDir, } from './queue.js';
5
+ export { HOOK_EVENTS, HOOK_TIMEOUTS, hookStatus, installHooks, uninstallHooks, } from './install.js';
6
+ export { importTranscripts } from './import.js';
7
+ //# sourceMappingURL=index.js.map