@mnemom/mnemom 0.17.4-next.2 → 0.17.4-next.4
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/base-url-proxy.mjs +154 -0
- package/dist/commands/agent-config.js +15 -0
- package/dist/commands/agent-first-run.d.ts +35 -0
- package/dist/commands/agent-first-run.js +78 -0
- package/dist/commands/agent.d.ts +24 -0
- package/dist/commands/agent.js +130 -14
- package/dist/index.js +10 -0
- package/dist/lib/agent.d.ts +112 -0
- package/dist/lib/agent.js +184 -0
- package/dist/lib/first-run/backtest.d.ts +92 -0
- package/dist/lib/first-run/backtest.js +196 -0
- package/dist/lib/first-run/governed.d.ts +65 -0
- package/dist/lib/first-run/governed.js +253 -0
- package/dist/lib/first-run/index.d.ts +75 -0
- package/dist/lib/first-run/index.js +255 -0
- package/dist/lib/first-run/lines.d.ts +16 -0
- package/dist/lib/first-run/lines.js +80 -0
- package/dist/lib/first-run/scope.d.ts +108 -0
- package/dist/lib/first-run/scope.js +308 -0
- package/dist/lib/first-run/screen.d.ts +91 -0
- package/dist/lib/first-run/screen.js +324 -0
- package/dist/lib/first-run/seat.d.ts +75 -0
- package/dist/lib/first-run/seat.js +250 -0
- package/dist/lib/login-detect.d.ts +1 -3
- package/dist/lib/login-detect.js +13 -17
- package/dist/rc-proxy.mjs +16 -1
- package/dist/turn-origin.mjs +330 -0
- package/package.json +1 -1
package/dist/lib/login-detect.js
CHANGED
|
@@ -66,8 +66,6 @@ export function unsupportedReason(login) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
export const MENU_SUBSCRIPTION = "Claude subscription — Team or Enterprise (your claude.ai login)";
|
|
69
|
-
export const MENU_CONSOLE = "Anthropic Console login (not supported yet)";
|
|
70
|
-
export const MENU_THIRD_PARTY = "Amazon Bedrock / Google Vertex AI / Microsoft Foundry (not supported yet)";
|
|
71
69
|
export const MENU_API_KEY = "Anthropic API key";
|
|
72
70
|
const SAVED_HINT = "change it any time with `mnemom agent config set auth api-key|subscription`";
|
|
73
71
|
function save(deps, auth, line) {
|
|
@@ -85,7 +83,7 @@ function save(deps, auth, line) {
|
|
|
85
83
|
* - No Claude login detected → api-key, silently, nothing saved (today's behaviour).
|
|
86
84
|
* - A login we can't use → api-key, say why once, save it so the note isn't repeated.
|
|
87
85
|
* - Team/Enterprise and non-interactive → api-key, nothing saved (never switch a script's
|
|
88
|
-
* auth without asking); interactive →
|
|
86
|
+
* auth without asking); interactive → a two-option menu (subscription or API key), answer saved.
|
|
89
87
|
*/
|
|
90
88
|
export async function chooseAuth(deps) {
|
|
91
89
|
const { login } = deps;
|
|
@@ -96,21 +94,19 @@ export async function chooseAuth(deps) {
|
|
|
96
94
|
}
|
|
97
95
|
if (!deps.interactive)
|
|
98
96
|
return "api-key";
|
|
99
|
-
|
|
97
|
+
// Only options that work. The earlier menu also listed Console and Bedrock/Vertex/Foundry
|
|
98
|
+
// as "(not supported yet)" entries that re-asked when picked: a dead end. Operator ruling
|
|
99
|
+
// (2026-10-01): the product never offers a choice it can't fulfil, and never advertises
|
|
100
|
+
// what's coming. A login type comes back here only once it works end to end. Those logins
|
|
101
|
+
// already skip this menu above with a one-line reason.
|
|
102
|
+
const options = [MENU_SUBSCRIPTION, MENU_API_KEY];
|
|
100
103
|
const question = `mnemom agent: Claude Code is signed in with your Claude ${login.plan} plan. How should it sign in through Mnemom?`;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return save(deps, "api-key", "mnemom agent: using an Anthropic API key.");
|
|
108
|
-
}
|
|
109
|
-
if (picked === MENU_CONSOLE || picked === MENU_THIRD_PARTY) {
|
|
110
|
-
deps.say("mnemom agent: that one isn't supported yet — pick 1 or 4.");
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
break; // no answer (EOF / out of range): don't loop forever
|
|
104
|
+
const picked = await deps.select(question, options);
|
|
105
|
+
if (picked === MENU_SUBSCRIPTION) {
|
|
106
|
+
return save(deps, "subscription", "mnemom agent: using your Claude subscription login.");
|
|
107
|
+
}
|
|
108
|
+
if (picked === MENU_API_KEY) {
|
|
109
|
+
return save(deps, "api-key", "mnemom agent: using an Anthropic API key.");
|
|
114
110
|
}
|
|
115
111
|
deps.say("mnemom agent: no choice made — using an Anthropic API key for this launch.");
|
|
116
112
|
return "api-key";
|
package/dist/rc-proxy.mjs
CHANGED
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
// MNEMOM_AGENT_RC_PROXY_LOG append log lines to this file instead of stderr (optional)
|
|
78
78
|
// MNEMOM_AGENT_RC_PARENT_PID exit when this pid is gone, so no orphan holds the keys
|
|
79
79
|
// MNEMOM_AGENT_RC_PORT listen port (default 0 = ephemeral)
|
|
80
|
+
// MNEMOM_AGENT_TURN_HEADER 0/off: no x-mnemom-turn header (default on in implicit mode;
|
|
81
|
+
// see turn-origin.mjs)
|
|
80
82
|
//
|
|
81
83
|
// stdout: exactly one line, `READY <port> <ca.pem path>`, once listening. Node ≥ 18, no deps.
|
|
82
84
|
import http from "node:http";
|
|
@@ -87,6 +89,7 @@ import os from "node:os";
|
|
|
87
89
|
import path from "node:path";
|
|
88
90
|
import crypto from "node:crypto";
|
|
89
91
|
import { execFileSync } from "node:child_process";
|
|
92
|
+
import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
|
|
90
93
|
|
|
91
94
|
const MITM_HOST = "api.anthropic.com";
|
|
92
95
|
const env = process.env;
|
|
@@ -118,6 +121,9 @@ if (THREAD_MODE !== "reject" && THREAD_MODE !== "strip")
|
|
|
118
121
|
const LOGFILE = env.MNEMOM_AGENT_RC_PROXY_LOG || "";
|
|
119
122
|
const PARENT = Number(env.MNEMOM_AGENT_RC_PARENT_PID || 0);
|
|
120
123
|
const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
|
|
124
|
+
// x-mnemom-turn (turn-origin.mjs): each main request's turn origins, read from Claude Code's own
|
|
125
|
+
// transcript. Implicit goal mode only — a sealed contract does not infer a goal from the user.
|
|
126
|
+
const TURNS = GOAL_MODE && turnHeaderEnabled(env) ? new TurnOrigins() : null;
|
|
121
127
|
|
|
122
128
|
const log = (line) => {
|
|
123
129
|
// One record per line: gateway header values and error bodies are spliced into log lines, so a
|
|
@@ -411,6 +417,7 @@ function handle(req, res, viaPlain) {
|
|
|
411
417
|
agent: gatewayAgent,
|
|
412
418
|
};
|
|
413
419
|
noteCcSession(headers["x-claude-code-session-id"]);
|
|
420
|
+
delete headers["x-mnemom-turn"]; // only this proxy states turn origins
|
|
414
421
|
if (!SUBSCRIPTION) {
|
|
415
422
|
delete headers.authorization; // the OAuth bearer stays local
|
|
416
423
|
headers["x-api-key"] = secrets.anthropic;
|
|
@@ -523,7 +530,15 @@ function handle(req, res, viaPlain) {
|
|
|
523
530
|
rejectThread(res, tag, thread);
|
|
524
531
|
return;
|
|
525
532
|
}
|
|
526
|
-
|
|
533
|
+
if (!TURNS) {
|
|
534
|
+
forward(body);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
turnHeaderFor(TURNS, headers).then((t) => {
|
|
538
|
+
if (t.value) headers[TURN_HEADER] = t.value;
|
|
539
|
+
log(`${tag} turn=${t.note}`);
|
|
540
|
+
forward(body);
|
|
541
|
+
});
|
|
527
542
|
});
|
|
528
543
|
} else {
|
|
529
544
|
forward();
|
|
@@ -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
|
+
}
|