@mnemom/mnemom 0.17.4-next.3 → 0.17.4-next.5

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,330 @@
1
+ // turn-origin.mjs — states where each recent user turn came from, read from Claude Code's OWN
2
+ // transcript, as the `x-mnemom-turn` request header (implicit goal mode, provenance design B).
3
+ //
4
+ // WHY. On the wire a cron / loop tick, a skill body, hook text, a channel relay and a peer message
5
+ // all go out as plain user text. Claude Code records the difference locally: every user entry in
6
+ // `<config>/projects/<cwd-slug>/<session-id>.jsonl` carries `promptId`, `promptSource`
7
+ // (typed / queued / system / sdk), `turnOrigin` (human / scheduled / peer / task_notification /
8
+ // system / sdk), `origin.kind` (human / task-notification / peer / channel), `isMeta` and
9
+ // `isCompactSummary`. The local proxy in front of Claude Code (rc-proxy.mjs under Remote Control,
10
+ // base-url-proxy.mjs in terminal mode) takes a request's `x-claude-code-prompt-id`, waits until
11
+ // the transcript holds an entry with that promptId (Claude Code writes it ~90 ms before the
12
+ // request leaves; we retry for up to WAIT_MS), and sends the recent prompt entries' facts:
13
+ //
14
+ // x-mnemom-turn: origin=scheduled; source=system; pastes=0; h=<sha256 hex of the text>, …
15
+ //
16
+ // One entry per text part (oldest first, at most TURN_PARTS_MAX). The gateway
17
+ // (gateway/src/goal-turn-claims.ts) uses it restrict-only: text whose hash matches a NON-human
18
+ // entry is never the user's words; nothing is ever promoted. No header (no prompt id, a transcript
19
+ // that is missing, unreadable or late, a schema without these fields) = the gateway's step-A
20
+ // behaviour. Only facts Claude Code itself recorded decide an origin: no text is inspected for
21
+ // meaning (the paste count reads Claude Code's own `<pasted_content id=…>` wrapper).
22
+ //
23
+ // Node >= 18, no dependencies. Never logs transcript text.
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import crypto from "node:crypto";
28
+
29
+ export const TURN_HEADER = "x-mnemom-turn";
30
+ /** Text parts sent per request (the gateway accepts up to 32). */
31
+ export const TURN_PARTS_MAX = 16;
32
+ /** How long a request waits for its prompt's transcript entry. */
33
+ export const WAIT_MS = 500;
34
+ const POLL_MS = 20;
35
+ /** Prompt entries kept in memory per transcript (only the newest TURN_PARTS_MAX parts are sent). */
36
+ const ENTRIES_KEPT = 64;
37
+ /** Prompt ids remembered per transcript before the set is reset. */
38
+ const PROMPT_IDS_MAX = 4096;
39
+ const READ_CHUNK = 256 * 1024;
40
+ const ID_RE = /^[A-Za-z0-9-]{8,64}$/;
41
+
42
+ /** The configured Claude Code home: CLAUDE_CONFIG_DIR, else ~/.claude. */
43
+ export function claudeConfigDir(env = process.env) {
44
+ return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
45
+ }
46
+
47
+ /** Claude Code's project directory name for a cwd: every non-alphanumeric byte becomes "-". */
48
+ export function cwdSlug(cwd) {
49
+ return String(cwd).replace(/[^A-Za-z0-9]/g, "-");
50
+ }
51
+
52
+ function contentOf(entry) {
53
+ return entry && entry.message ? entry.message.content : undefined;
54
+ }
55
+
56
+ /**
57
+ * A prompt entry: a user entry that is not a tool result and not a sidechain (a subagent's own
58
+ * transcript lives in its own file). Tool results carry the prompt id too, but they are the
59
+ * tools' output, never a turn.
60
+ */
61
+ export function isPromptEntry(entry) {
62
+ if (!entry || entry.type !== "user" || entry.isSidechain === true) return false;
63
+ const c = contentOf(entry);
64
+ if (typeof c === "string") return true;
65
+ if (!Array.isArray(c)) return false;
66
+ return !c.some((b) => b && b.type === "tool_result");
67
+ }
68
+
69
+ /**
70
+ * The origin Claude Code recorded for a prompt entry, or null when it recorded none we know
71
+ * (then the entry is not stated at all, and the gateway reads that text as before).
72
+ * Precedence: summary / transcript-only → meta; channel; scheduled; peer; task; isMeta → meta;
73
+ * a human origin or a typed / queued prompt → human.
74
+ */
75
+ export function entryOrigin(entry) {
76
+ if (!entry) return null;
77
+ const turn = entry.turnOrigin;
78
+ const kind = entry.origin && typeof entry.origin === "object" ? entry.origin.kind : undefined;
79
+ if (entry.isCompactSummary === true || entry.isVisibleInTranscriptOnly === true) return "meta";
80
+ if (kind === "channel") return "channel";
81
+ if (turn === "scheduled" || (typeof entry.scheduledTaskId === "string" && entry.scheduledTaskId))
82
+ return "scheduled";
83
+ if (turn === "peer" || kind === "peer") return "peer";
84
+ if (turn === "task_notification" || kind === "task-notification") return "task";
85
+ if (entry.isMeta === true || turn === "system") return "meta";
86
+ if (turn === "human" || kind === "human") return "human";
87
+ if (entry.promptSource === "typed" || entry.promptSource === "queued") return "human";
88
+ return null;
89
+ }
90
+
91
+ const SOURCES = new Set(["typed", "queued", "system", "sdk"]);
92
+
93
+ /** `promptSource` when it is one of the four Claude Code records, else undefined. */
94
+ export function entrySource(entry) {
95
+ return entry && SOURCES.has(entry.promptSource) ? entry.promptSource : undefined;
96
+ }
97
+
98
+ /** The entry's text parts as Claude Code sends them: the string, or each text block's text. */
99
+ export function entryTexts(entry) {
100
+ const c = contentOf(entry);
101
+ if (typeof c === "string") return c ? [c] : [];
102
+ if (!Array.isArray(c)) return [];
103
+ return c
104
+ .filter((b) => b && b.type === "text" && typeof b.text === "string" && b.text)
105
+ .map((b) => b.text);
106
+ }
107
+
108
+ /**
109
+ * Pastes in an entry: Claude Code's `<pasted_content id="X">…</pasted_content id="X">` wrappers
110
+ * whose open and close ids match, plus pasted images (`imagePasteIds`).
111
+ */
112
+ export function countPastes(entry) {
113
+ let n = Array.isArray(entry && entry.imagePasteIds) ? entry.imagePasteIds.length : 0;
114
+ for (const text of entryTexts(entry)) {
115
+ let i = 0;
116
+ for (;;) {
117
+ const open = text.indexOf('<pasted_content id="', i);
118
+ if (open < 0) break;
119
+ const idFrom = open + '<pasted_content id="'.length;
120
+ const idEnd = text.indexOf('">', idFrom);
121
+ if (idEnd < 0) break;
122
+ const id = text.slice(idFrom, idEnd);
123
+ const close = `</pasted_content id="${id}">`;
124
+ const at = text.indexOf(close, idEnd + 2);
125
+ if (at < 0) {
126
+ i = idEnd + 2;
127
+ continue;
128
+ }
129
+ n++;
130
+ i = at + close.length;
131
+ }
132
+ }
133
+ return n;
134
+ }
135
+
136
+ export const sha256Hex = (text) => crypto.createHash("sha256").update(text, "utf8").digest("hex");
137
+
138
+ /** The header value for a list of prompt entries (oldest first). "" when nothing is stated. */
139
+ export function buildTurnHeader(entries, max = TURN_PARTS_MAX) {
140
+ const parts = [];
141
+ for (const e of entries) {
142
+ const origin = entryOrigin(e);
143
+ if (!origin) continue;
144
+ const source = entrySource(e);
145
+ const pastes = Math.min(countPastes(e), 9999);
146
+ for (const text of entryTexts(e)) {
147
+ parts.push(
148
+ `origin=${origin}; ${source ? `source=${source}; ` : ""}pastes=${pastes}; h=${sha256Hex(text)}`,
149
+ );
150
+ }
151
+ }
152
+ return parts.slice(-max).join(", ");
153
+ }
154
+
155
+ /** Incremental reader of one transcript file: parses only the bytes appended since last time. */
156
+ export class TranscriptIndex {
157
+ constructor(file) {
158
+ this.file = file;
159
+ this.offset = 0;
160
+ this.partial = "";
161
+ this.entries = [];
162
+ this.promptIds = new Set();
163
+ }
164
+
165
+ reset() {
166
+ this.offset = 0;
167
+ this.partial = "";
168
+ this.entries = [];
169
+ this.promptIds = new Set();
170
+ }
171
+
172
+ /** Read whatever was appended. Returns false when the file cannot be read. */
173
+ refresh() {
174
+ let fd;
175
+ try {
176
+ fd = fs.openSync(this.file, "r");
177
+ const size = fs.fstatSync(fd).size;
178
+ if (size < this.offset) this.reset(); // rewritten / truncated: start over
179
+ // Read to EOF (a 0-byte read), not to the size above: Claude Code may append meanwhile.
180
+ const buf = Buffer.alloc(READ_CHUNK);
181
+ for (let n; (n = fs.readSync(fd, buf, 0, buf.length, this.offset)) > 0; ) {
182
+ this.offset += n;
183
+ this.ingest(buf.subarray(0, n).toString("utf8"));
184
+ }
185
+ return true;
186
+ } catch {
187
+ return false;
188
+ } finally {
189
+ if (fd !== undefined) fs.closeSync(fd);
190
+ }
191
+ }
192
+
193
+ ingest(chunk) {
194
+ const text = this.partial + chunk;
195
+ const lines = text.split("\n");
196
+ this.partial = lines.pop() ?? "";
197
+ for (const line of lines) this.ingestLine(line);
198
+ }
199
+
200
+ ingestLine(line) {
201
+ if (!line || line.indexOf('"promptId"') < 0) return;
202
+ let e;
203
+ try {
204
+ e = JSON.parse(line);
205
+ } catch {
206
+ return;
207
+ }
208
+ if (!isPromptEntry(e) || typeof e.promptId !== "string") return;
209
+ if (this.promptIds.size >= PROMPT_IDS_MAX) this.promptIds.clear();
210
+ this.promptIds.add(e.promptId);
211
+ this.entries.push(e);
212
+ if (this.entries.length > ENTRIES_KEPT)
213
+ this.entries.splice(0, this.entries.length - ENTRIES_KEPT);
214
+ }
215
+
216
+ has(promptId) {
217
+ return this.promptIds.has(promptId);
218
+ }
219
+
220
+ header() {
221
+ return buildTurnHeader(this.entries);
222
+ }
223
+ }
224
+
225
+ const one = (v) => (Array.isArray(v) ? v[0] : v);
226
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
227
+
228
+ /**
229
+ * Builds `x-mnemom-turn` values for requests. One instance per proxy process; it caches each
230
+ * session's transcript location and incremental index.
231
+ */
232
+ export class TurnOrigins {
233
+ constructor(opts = {}) {
234
+ this.configDir = opts.configDir || claudeConfigDir();
235
+ this.cwd = opts.cwd || process.cwd();
236
+ this.waitMs = opts.waitMs ?? WAIT_MS;
237
+ this.indexes = new Map();
238
+ this.misses = new Map();
239
+ }
240
+
241
+ /** The transcript file for a session id: the cwd's project dir first, then every project dir. */
242
+ locate(sessionId) {
243
+ const projects = path.join(this.configDir, "projects");
244
+ const name = `${sessionId}.jsonl`;
245
+ const first = path.join(projects, cwdSlug(this.cwd), name);
246
+ if (fs.existsSync(first)) return first;
247
+ const missAt = this.misses.get(sessionId);
248
+ if (missAt !== undefined && Date.now() - missAt < 1000) return null;
249
+ try {
250
+ for (const d of fs.readdirSync(projects)) {
251
+ const cand = path.join(projects, d, name);
252
+ if (fs.existsSync(cand)) {
253
+ this.misses.delete(sessionId);
254
+ return cand;
255
+ }
256
+ }
257
+ } catch {
258
+ /* no projects dir */
259
+ }
260
+ if (this.misses.size > 256) this.misses.clear();
261
+ this.misses.set(sessionId, Date.now());
262
+ return null;
263
+ }
264
+
265
+ index(sessionId) {
266
+ let ix = this.indexes.get(sessionId);
267
+ if (ix && fs.existsSync(ix.file)) return ix;
268
+ const file = this.locate(sessionId);
269
+ if (!file) return null;
270
+ if (this.indexes.size > 64) this.indexes.clear();
271
+ ix = new TranscriptIndex(file);
272
+ this.indexes.set(sessionId, ix);
273
+ return ix;
274
+ }
275
+
276
+ /**
277
+ * The header for a request, from its (lower-cased) headers: `{ value, note }`. `value` is ""
278
+ * when nothing is stated; `note` is a log word (never transcript text).
279
+ *
280
+ * Main requests only. The prompt id is the SYNC point: with one, wait (up to waitMs) until the
281
+ * transcript holds that prompt, so the newest turn is stated too. Claude Code sends no prompt id
282
+ * on some main requests (a slash command / skill turn, measured on 2.1.285): those get the
283
+ * transcript as it stands, no wait. Either way every entry sent is a recorded fact, and an entry
284
+ * that is not in the transcript yet is simply not stated (the gateway reads that text as before).
285
+ * Notes: `skip` (not main / no session id), `n=<parts>` (synced), `nosync n=<parts>` (no prompt
286
+ * id), `late n=<parts>` (the prompt never appeared in time), `none` (no transcript entries).
287
+ */
288
+ async forRequest(headers) {
289
+ const cls = String(one(headers["x-claude-code-request-class"]) ?? "");
290
+ const promptId = String(one(headers["x-claude-code-prompt-id"]) ?? "").trim();
291
+ const sessionId = String(one(headers["x-claude-code-session-id"]) ?? "").trim();
292
+ if (cls !== "main" || !ID_RE.test(sessionId)) return { value: "", note: "skip" };
293
+ const synced = ID_RE.test(promptId);
294
+ const deadline = Date.now() + (synced ? this.waitMs : 0);
295
+ let ix;
296
+ let tag = synced ? "" : "nosync ";
297
+ for (;;) {
298
+ ix = this.index(sessionId);
299
+ const ok = ix ? ix.refresh() : false;
300
+ if (ok && (!synced || ix.has(promptId))) break;
301
+ if (Date.now() >= deadline) {
302
+ if (synced) tag = "late ";
303
+ break;
304
+ }
305
+ await sleep(POLL_MS);
306
+ }
307
+ const value = ix ? ix.header() : "";
308
+ return { value, note: value ? `${tag}n=${value.split(", ").length}` : "none" };
309
+ }
310
+ }
311
+
312
+ /**
313
+ * `turns.forRequest(headers)`, but never rejects: an unexpected error reads as `{ value: "",
314
+ * note: "error" }`, so a proxy forwards the request without the header instead of failing it.
315
+ */
316
+ export async function turnHeaderFor(turns, headers) {
317
+ try {
318
+ return await turns.forRequest(headers);
319
+ } catch {
320
+ return { value: "", note: "error" };
321
+ }
322
+ }
323
+
324
+ /** Whether a launch asked for the header (on unless MNEMOM_AGENT_TURN_HEADER is 0/off/false/no). */
325
+ export function turnHeaderEnabled(env = process.env) {
326
+ const raw = String(env.MNEMOM_AGENT_TURN_HEADER ?? "")
327
+ .trim()
328
+ .toLowerCase();
329
+ return !(raw === "0" || raw === "off" || raw === "false" || raw === "no");
330
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.4-next.3",
3
+ "version": "0.17.4-next.5",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {