open-claude-p 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ // Per-cwd session log under `<cwd>/.ocp/<sessionId>/`.
2
+ //
3
+ // Each turn writes four files:
4
+ // - prompt.txt the user prompt verbatim
5
+ // - response.md the assistant's clean markdown reply
6
+ // - meta.json timestamps, tokens, cost, tools, completion reason
7
+ // - events.jsonl one event per line (omitted when no events present)
8
+ //
9
+ // Also appends one line to `<cwd>/.ocp/sessions.jsonl` for chronological
10
+ // listing (sessionId, ts, summary).
11
+ //
12
+ // Failure mode: best-effort. Logging errors NEVER propagate to the
13
+ // chat result — a cwd that's read-only just means no log, not a failed
14
+ // request. We DO emit a one-shot warning so silent failure isn't
15
+ // totally invisible.
16
+ //
17
+ // The directory is auto-created with mode 0o700 and refused if it
18
+ // already exists with looser perms or different owner — same trust
19
+ // stance as the lock dir.
20
+
21
+ import path from 'node:path';
22
+ import { mkdir, lstat, chmod, writeFile, appendFile } from 'node:fs/promises';
23
+
24
+ const SESSIONS_DIR_NAME = '.ocp';
25
+
26
+ let dirVerifiedFor = new Map(); // cwd → boolean
27
+ let dirWarned = false;
28
+
29
+ function emitOnce(msg) {
30
+ if (dirWarned) return;
31
+ dirWarned = true;
32
+ try { process.emitWarning(msg, { type: 'OcpSessionLogUnsafe' }); } catch {}
33
+ }
34
+
35
+ async function ensureSessionsDir(cwd) {
36
+ const dir = path.join(cwd, SESSIONS_DIR_NAME);
37
+ if (dirVerifiedFor.get(cwd)) return dir;
38
+ try {
39
+ await mkdir(dir, { recursive: true, mode: 0o700 });
40
+ const st = await lstat(dir);
41
+ if (st.isSymbolicLink()) {
42
+ emitOnce(`ocp: refusing to write session log — ${dir} is a symbolic link`);
43
+ return null;
44
+ }
45
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
46
+ emitOnce(`ocp: refusing to write session log — ${dir} owned by uid ${st.uid}`);
47
+ return null;
48
+ }
49
+ if ((st.mode & 0o077) !== 0) {
50
+ // Group/other-readable — try to tighten but don't fail if chmod
51
+ // can't (we still own it, so the contents leak is bounded to the
52
+ // owner's reads).
53
+ await chmod(dir, 0o700).catch(() => {});
54
+ }
55
+ dirVerifiedFor.set(cwd, true);
56
+ return dir;
57
+ } catch (e) {
58
+ if (e?.code !== 'EACCES' && e?.code !== 'EROFS') {
59
+ emitOnce(`ocp: session log dir ${dir} unusable (${e?.code || e?.message})`);
60
+ }
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Record a single turn under `<cwd>/.ocp/<sessionId>/`.
67
+ *
68
+ * @param {object} args
69
+ * @param {string} args.cwd
70
+ * @param {string|null} args.sessionId claude sessionId (UUID); falls back to ts-suffix if null
71
+ * @param {string} args.prompt original user prompt
72
+ * @param {string} args.response assistant text (clean markdown)
73
+ * @param {object} [args.meta] {durationMs, inputTokens, outputTokens, costUsd, tools, completionReason, isError}
74
+ * @param {Array<object>} [args.events] full event stream (writes events.jsonl when non-empty)
75
+ */
76
+ export async function recordSession({ cwd, sessionId, prompt, response, meta, events }) {
77
+ if (!cwd || typeof cwd !== 'string') return;
78
+ const baseDir = await ensureSessionsDir(cwd);
79
+ if (!baseDir) return;
80
+ // Fall back to a synthetic id when claude didn't return one (e.g.
81
+ // error before session-init). Use timestamp so the dir name is still
82
+ // sortable, prefixed so it can't collide with a real UUID.
83
+ const id = sessionId || `noid-${Date.now()}`;
84
+ const turnDir = path.join(baseDir, id);
85
+ try {
86
+ await mkdir(turnDir, { recursive: true, mode: 0o700 });
87
+ const ts = new Date().toISOString();
88
+ const writes = [
89
+ writeFile(path.join(turnDir, 'prompt.txt'), prompt ?? '', { mode: 0o600 }),
90
+ writeFile(path.join(turnDir, 'response.md'), response ?? '', { mode: 0o600 }),
91
+ writeFile(
92
+ path.join(turnDir, 'meta.json'),
93
+ JSON.stringify({ sessionId: sessionId ?? null, timestamp: ts, ...(meta ?? {}) }, null, 2),
94
+ { mode: 0o600 },
95
+ ),
96
+ ];
97
+ if (Array.isArray(events) && events.length > 0) {
98
+ // Cap to avoid an unbounded write — events can balloon to MB on
99
+ // long sessions. Keep the tail (most recent), the head usually
100
+ // is just framing/prompt-box init noise.
101
+ const MAX_EVENTS_LOG = 2000;
102
+ const slice = events.length > MAX_EVENTS_LOG ? events.slice(-MAX_EVENTS_LOG) : events;
103
+ writes.push(writeFile(
104
+ path.join(turnDir, 'events.jsonl'),
105
+ slice.map((e) => JSON.stringify(e)).join('\n') + '\n',
106
+ { mode: 0o600 },
107
+ ));
108
+ }
109
+ await Promise.all(writes);
110
+ // Append an index line so a single `tail -f .ocp/sessions.jsonl`
111
+ // gives a chronological feed of all turns in this project.
112
+ const indexLine = JSON.stringify({
113
+ ts,
114
+ sessionId: sessionId ?? null,
115
+ promptPreview: (prompt ?? '').slice(0, 80),
116
+ isError: !!meta?.isError,
117
+ durationMs: meta?.durationMs ?? null,
118
+ tools: meta?.tools ?? [],
119
+ }) + '\n';
120
+ await appendFile(path.join(baseDir, 'sessions.jsonl'), indexLine, { mode: 0o600 }).catch(() => {});
121
+ } catch (e) {
122
+ emitOnce(`ocp: session log write failed for ${turnDir} (${e?.code || e?.message})`);
123
+ }
124
+ }