@mathew-macharia/black-mamba 1.0.4 → 1.0.6

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/src/config.js CHANGED
@@ -30,7 +30,6 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
30
30
  return Object.freeze({
31
31
  rootDir,
32
32
  port: readNumber(env.PORT, 3000),
33
- apiKey: (env.API_KEY || env.BM_API_KEY || "").trim(),
34
33
  debug: env.BM_DEBUG === "1" || env.BM_DEBUG === "true",
35
34
 
36
35
  deepseek: Object.freeze({
@@ -45,6 +44,9 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
45
44
  healthCheckIntervalMs: 30 * 60 * 1000,
46
45
  estimatedWafTtlMs: 4 * 24 * 60 * 60 * 1000,
47
46
  estimatedSessionTtlMs: 30 * 24 * 60 * 60 * 1000,
47
+ // DeepSeek biz codes that mean "the chat session / parent message you referenced no longer exists".
48
+ // Only these (plus a narrow message pattern) trigger an explicit re-seed of a stateful branch.
49
+ staleContextBizCodes: readList(env.DEEPSEEK_STALE_CONTEXT_BIZ_CODES).map(Number),
48
50
  }),
49
51
 
50
52
  server: Object.freeze({
@@ -52,6 +54,12 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
52
54
  keepAliveIntervalMs: 10_000,
53
55
  }),
54
56
 
57
+ sessions: Object.freeze({
58
+ dir: path.join(rootDir, ".sessions"),
59
+ ttlMs: 14 * 24 * 60 * 60 * 1000,
60
+ maxEntries: 1000,
61
+ }),
62
+
55
63
  pacing: Object.freeze({
56
64
  minTurnDelayMs: readNumber(env.MIN_TURN_DELAY_MS, 800),
57
65
  maxTurnDelayMs: readNumber(env.MAX_TURN_DELAY_MS, 2000),
@@ -0,0 +1,119 @@
1
+ // Terminal interaction for loading DeepSeek credentials and keeping them healthy.
2
+
3
+ import readline from "node:readline";
4
+ import { CONFIG } from "../config.js";
5
+ import { createLogger } from "../logger.js";
6
+ import { parseCurlCommand } from "./curl-parser.js";
7
+
8
+ const log = createLogger("Auth");
9
+
10
+ export function readCurlFromTerminal(promptLabel, { input = process.stdin, output = process.stdout } = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const rl = readline.createInterface({ input, output, terminal: Boolean(input.isTTY) });
13
+ console.log(`\n${promptLabel}`);
14
+ console.log(" (Chrome DevTools -> Network -> right-click 'completion' -> Copy as cURL or Copy as PowerShell, paste below, then press Enter)\n");
15
+
16
+ const lines = [];
17
+ let resolved = false;
18
+ const finish = () => {
19
+ if (resolved) return;
20
+ resolved = true;
21
+ rl.close();
22
+ resolve(lines.join("\n"));
23
+ };
24
+
25
+ rl.on("line", (line) => {
26
+ const trimmed = line.trim();
27
+ if (trimmed === "" && lines.length > 0) {
28
+ const prev = lines[lines.length - 1].trim();
29
+ if (!prev.endsWith("^") && !prev.endsWith("\\") && !prev.endsWith("`")) {
30
+ finish();
31
+ return;
32
+ }
33
+ }
34
+ if (trimmed !== "" || lines.length > 0) lines.push(line);
35
+ });
36
+
37
+ rl.on("close", () => {
38
+ if (resolved) return;
39
+ resolved = true;
40
+ if (lines.length === 0) reject(new Error("Input stream closed before receiving cURL command"));
41
+ else resolve(lines.join("\n"));
42
+ });
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Owns the interactive credential lifecycle: initial paste, re-prompt on expiry, periodic health checks.
48
+ * @param {{ auth: import("./auth.js").DeepSeekAuth, fetchImpl?: typeof fetch, sanitize: (err: unknown) => string, port?: number }} deps
49
+ */
50
+ export function createAuthSupervisor({ auth, fetchImpl = fetch, sanitize, port = CONFIG.port, config = CONFIG.deepseek }) {
51
+ let promptInProgress = false;
52
+ let healthTimer = null;
53
+
54
+ function printSummary() {
55
+ const summary = auth.statusSummary();
56
+ log.info(`Session active | Bearer token: ${auth.token ? "yes" : "no"} | Cookies in RAM: ${auth.cookies.size}`);
57
+ if (summary.next_due_expiry) {
58
+ log.info(`Earliest cookie due: "${summary.next_due_expiry.cookie}" in ${summary.next_due_expiry.remaining}`);
59
+ }
60
+ log.info(`Live expiry details available anytime at GET http://localhost:${port}/`);
61
+ }
62
+
63
+ async function promptAndLoad(promptLabel = "📋 Paste your DeepSeek cURL command:") {
64
+ if (promptInProgress) return;
65
+ promptInProgress = true;
66
+ try {
67
+ while (true) {
68
+ const rawCurl = await readCurlFromTerminal(promptLabel);
69
+ try {
70
+ auth.applyParsedSession(parseCurlCommand(rawCurl));
71
+ await auth.verify(fetchImpl);
72
+ printSummary();
73
+ return;
74
+ } catch (err) {
75
+ log.error(`❌ ${sanitize(err)}`);
76
+ if (!process.stdin.isTTY) throw err;
77
+ promptLabel = "📋 Please paste a fresh, valid DeepSeek cURL command:";
78
+ }
79
+ }
80
+ } finally {
81
+ promptInProgress = false;
82
+ }
83
+ }
84
+
85
+ function reprompt(reason) {
86
+ auth.markInvalid();
87
+ log.warn(`⚠️ ${reason}`);
88
+ if (process.stdin.isTTY && !promptInProgress) {
89
+ promptAndLoad("🔄 Session expired! Paste a fresh DeepSeek cURL command to resume:").catch((err) => {
90
+ log.error("Failed to reload session:", sanitize(err));
91
+ });
92
+ }
93
+ }
94
+
95
+ function startHealthMonitor() {
96
+ stopHealthMonitor();
97
+ healthTimer = setInterval(async () => {
98
+ if (!auth.verified || promptInProgress) return;
99
+ try {
100
+ await auth.verify(fetchImpl);
101
+ const summary = auth.statusSummary();
102
+ const expiryInfo = summary.next_due_expiry ? `${summary.next_due_expiry.cookie} due in ${summary.next_due_expiry.remaining}` : "active";
103
+ log.info(`Health check OK (age: ${summary.session_age}, ${expiryInfo})`);
104
+ } catch (err) {
105
+ reprompt(sanitize(err));
106
+ }
107
+ }, config.healthCheckIntervalMs);
108
+ healthTimer.unref?.();
109
+ }
110
+
111
+ function stopHealthMonitor() {
112
+ if (healthTimer) {
113
+ clearInterval(healthTimer);
114
+ healthTimer = null;
115
+ }
116
+ }
117
+
118
+ return Object.freeze({ promptAndLoad, reprompt, startHealthMonitor, stopHealthMonitor });
119
+ }
@@ -35,3 +35,18 @@ export function deepSeekAuthFacts(res, jsonBody = null) {
35
35
  }
36
36
  return facts;
37
37
  }
38
+
39
+ /**
40
+ * True only when DeepSeek tells us the chat session or parent message we referenced is gone.
41
+ * Deliberately narrow: anything else is a real failure that must surface to the client.
42
+ */
43
+ export function isStaleContextRejection(res, jsonBody, staleBizCodes = []) {
44
+ if (!(res.status === 400 || res.status === 404 || res.status === 200)) return false;
45
+ const bizCode = jsonBody?.data?.biz_code ?? jsonBody?.code;
46
+ if (typeof bizCode === "number" && staleBizCodes.includes(bizCode)) return true;
47
+ const msg = String(jsonBody?.data?.biz_msg || jsonBody?.msg || "").toLowerCase();
48
+ if (!msg) return false;
49
+ const mentionsContext = /(chat[_ ]?session|parent[_ ]?message|message[_ ]?id)/.test(msg);
50
+ const mentionsGone = /(not\s*found|invalid|expired|deleted|does not exist|not exist)/.test(msg);
51
+ return mentionsContext && mentionsGone;
52
+ }
@@ -1,11 +1,12 @@
1
1
  // HTTP client for the DeepSeek web API. Every failure is classified and thrown; nothing is papered over.
2
2
 
3
3
  import { CONFIG } from "../config.js";
4
- import { DeepSeekAuthError, UpstreamError } from "../errors.js";
4
+ import { DeepSeekAuthError, UpstreamContextRejectedError, UpstreamError } from "../errors.js";
5
5
  import {
6
6
  deepSeekAuthFacts,
7
7
  isDeepSeekAuthFailure,
8
8
  isHumanChallengeRequired,
9
+ isStaleContextRejection,
9
10
  isWafChallengeResponse,
10
11
  upstreamReason,
11
12
  } from "./classify.js";
@@ -95,19 +96,21 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
95
96
  }
96
97
 
97
98
  /**
98
- * Opens a completion stream on a fresh DeepSeek chat session. Returns the raw SSE body; decoding lives in sse.js.
99
- * @param {{ prompt: string, thinkingEnabled: boolean, searchEnabled: boolean, chatSessionId?: string, signal?: AbortSignal }} params
99
+ * Opens a completion stream. Returns the raw SSE body; decoding lives in sse.js.
100
+ * @param {{ chatSessionId: string, parentMessageId: number|null, prompt: string, thinkingEnabled: boolean, searchEnabled: boolean, signal?: AbortSignal }} params
100
101
  */
101
- async function openCompletionStream({ prompt, thinkingEnabled, searchEnabled, chatSessionId, signal }) {
102
+ async function openCompletionStream({ chatSessionId, parentMessageId, prompt, thinkingEnabled, searchEnabled, signal }) {
102
103
  ensureCredentials();
103
- const sessionId = chatSessionId || (await createChatSession());
104
+ if (typeof chatSessionId !== "string" || !chatSessionId) {
105
+ throw new Error("openCompletionStream requires a DeepSeek chat session id");
106
+ }
104
107
 
105
108
  const powHeader = powSolver.solve(await fetchPowChallenge());
106
109
  const res = await postJson(
107
110
  config.chatCompletionUrl,
108
111
  {
109
- chat_session_id: sessionId,
110
- parent_message_id: null,
112
+ chat_session_id: chatSessionId,
113
+ parent_message_id: parentMessageId,
111
114
  model_type: "default",
112
115
  prompt,
113
116
  ref_file_ids: [],
@@ -136,7 +139,10 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
136
139
  if (isDeepSeekAuthFailure(res, errJson)) {
137
140
  throw authFailure(isHumanChallengeRequired(errJson) ? HUMAN_CHALLENGE_NOTICE : SESSION_EXPIRED_NOTICE, res, errJson);
138
141
  }
139
- const details = { status: res.status, body: errJson ?? rawText.slice(0, 500), chat_session_id: sessionId };
142
+ const details = { status: res.status, body: errJson ?? rawText.slice(0, 500), chat_session_id: chatSessionId, parent_message_id: parentMessageId };
143
+ if (isStaleContextRejection(res, errJson, config.staleContextBizCodes)) {
144
+ throw new UpstreamContextRejectedError(`DeepSeek rejected the referenced chat context: ${upstreamReason(errJson, rawText, res.status)}`, details);
145
+ }
140
146
  throw new UpstreamError(`DeepSeek completion failed: ${upstreamReason(errJson, rawText, res.status)}`, { details });
141
147
  }
142
148
 
@@ -145,7 +151,7 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
145
151
  }
146
152
 
147
153
  auth.markVerified();
148
- return { streamBody: res.body, chatSessionId: sessionId };
154
+ return { streamBody: res.body };
149
155
  }
150
156
 
151
157
  return Object.freeze({ createChatSession, openCompletionStream, fetchPowChallenge });
package/src/errors.js CHANGED
@@ -45,3 +45,14 @@ export class UpstreamError extends HttpError {
45
45
  this.name = "UpstreamError";
46
46
  }
47
47
  }
48
+
49
+ /**
50
+ * Raised when DeepSeek explicitly rejected the chat session / parent message we referenced.
51
+ * The completion handler converts this into an explicit, logged re-seed for stateful branches.
52
+ */
53
+ export class UpstreamContextRejectedError extends UpstreamError {
54
+ constructor(message, details) {
55
+ super(message, { statusCode: 502, code: "deepseek_context_rejected", details });
56
+ this.name = "UpstreamContextRejectedError";
57
+ }
58
+ }
@@ -0,0 +1,130 @@
1
+ // Canonical view of OpenAI-format messages and the fingerprints Black Mamba uses to verify that the context pi sends
2
+ // matches the context DeepSeek already holds. Hashes are compared, never used as keys.
3
+
4
+ import { BadRequestError } from "../errors.js";
5
+ import { canonicalJson, sha256Hex, shortHash } from "../utils/hash.js";
6
+
7
+ /** Exact prefix pi uses when it turns a compaction entry into a user message (pi-coding-agent core/messages.js). */
8
+ export const PI_COMPACTION_SUMMARY_PREFIX =
9
+ "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
10
+ /** Exact prefix pi uses for branch summaries after /tree navigation. */
11
+ export const PI_BRANCH_SUMMARY_PREFIX = "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
12
+
13
+ export function normalizeRole(role) {
14
+ return typeof role === "string" ? role.trim().toLowerCase() : "";
15
+ }
16
+
17
+ /**
18
+ * Renders message content to text. Non-text parts are made visible as explicit placeholders instead of being dropped.
19
+ */
20
+ export function contentToText(content) {
21
+ if (content === null || content === undefined) return "";
22
+ if (typeof content === "string") return content;
23
+ if (Array.isArray(content)) {
24
+ return content
25
+ .map((part) => {
26
+ if (!part || typeof part !== "object") return "[unsupported content part]";
27
+ if (part.type === "text") return String(part.text ?? "");
28
+ if (part.type === "image_url" || part.type === "image") return "[image omitted: Black Mamba forwards text only]";
29
+ return `[unsupported content part: ${String(part.type)}]`;
30
+ })
31
+ .join("\n");
32
+ }
33
+ throw new BadRequestError("Message 'content' must be a string or an array of content parts", "invalid_message");
34
+ }
35
+
36
+ /** Parses tool-call arguments; returns { value, raw } where raw is kept only when the string is not valid JSON. */
37
+ export function parseToolArguments(args) {
38
+ if (args === undefined || args === null) return { value: {} };
39
+ if (typeof args === "object") return { value: args };
40
+ if (typeof args === "string") {
41
+ try {
42
+ return { value: JSON.parse(args) };
43
+ } catch {
44
+ return { value: null, raw: args };
45
+ }
46
+ }
47
+ return { value: null, raw: String(args) };
48
+ }
49
+
50
+ export function toolCallName(tc) {
51
+ return tc?.function?.name ?? tc?.name ?? "";
52
+ }
53
+
54
+ export function toolCallArguments(tc) {
55
+ return tc?.function?.arguments ?? tc?.arguments;
56
+ }
57
+
58
+ /** Structural view of a message that ignores presentation-only fields (reasoning_content, name, ids). */
59
+ export function canonicalizeMessage(msg) {
60
+ const role = normalizeRole(msg.role);
61
+ const canonical = { role, content: msg.content === null || msg.content === undefined ? null : contentToText(msg.content) };
62
+ if (role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
63
+ canonical.tool_calls = msg.tool_calls.map((tc) => {
64
+ const parsed = parseToolArguments(toolCallArguments(tc));
65
+ return { name: toolCallName(tc), arguments: parsed.raw !== undefined ? parsed.raw : canonicalJson(parsed.value) };
66
+ });
67
+ }
68
+ if (role === "tool") canonical.tool_call_id = msg.tool_call_id ?? null;
69
+ return canonical;
70
+ }
71
+
72
+ export function messageHash(msg) {
73
+ return shortHash(canonicalJson(canonicalizeMessage(msg)));
74
+ }
75
+
76
+ export function hasLeadingSystem(messages) {
77
+ const role = normalizeRole(messages[0]?.role);
78
+ return role === "system" || role === "developer";
79
+ }
80
+
81
+ /** Hash of the leading system message (or "none"). The system prompt is verified separately from the conversation body. */
82
+ export function systemHash(messages) {
83
+ return hasLeadingSystem(messages) ? messageHash(messages[0]) : "none";
84
+ }
85
+
86
+ /**
87
+ * Chain hash over the conversation body (every message except a leading system message) of the first `count` messages.
88
+ * Order-sensitive, so insertions, omissions and edits anywhere in the prefix are detected.
89
+ */
90
+ export function prefixHash(messages, count = messages.length) {
91
+ const start = hasLeadingSystem(messages) ? 1 : 0;
92
+ let acc = "bm-v2";
93
+ for (let i = start; i < count; i++) {
94
+ acc = sha256Hex(`${acc}:${messageHash(messages[i])}`);
95
+ }
96
+ return acc.slice(0, 32);
97
+ }
98
+
99
+ /** Fingerprint of an assistant reply as Black Mamba produced it (streamed text + emitted tool calls). */
100
+ export function assistantFingerprint({ text, toolCalls = [] }) {
101
+ return {
102
+ textHash: shortHash((text ?? "").trim()),
103
+ toolCalls: toolCalls.map((tc) => {
104
+ const parsed = parseToolArguments(tc.argumentsString ?? tc.arguments);
105
+ return { name: tc.name, argsHash: shortHash(parsed.raw !== undefined ? parsed.raw : canonicalJson(parsed.value)) };
106
+ }),
107
+ };
108
+ }
109
+
110
+ /** Fingerprint of an assistant message as pi replays it in a later request. */
111
+ export function assistantFingerprintFromMessage(msg) {
112
+ return assistantFingerprint({
113
+ text: contentToText(msg.content),
114
+ toolCalls: Array.isArray(msg.tool_calls) ? msg.tool_calls.map((tc) => ({ name: toolCallName(tc), arguments: toolCallArguments(tc) })) : [],
115
+ });
116
+ }
117
+
118
+ export function fingerprintsEqual(a, b) {
119
+ if (!a || !b) return false;
120
+ if (a.textHash !== b.textHash || a.toolCalls.length !== b.toolCalls.length) return false;
121
+ return a.toolCalls.every((tc, i) => tc.name === b.toolCalls[i].name && tc.argsHash === b.toolCalls[i].argsHash);
122
+ }
123
+
124
+ export function isCompactionSummaryMessage(msg) {
125
+ return normalizeRole(msg?.role) === "user" && contentToText(msg.content).startsWith(PI_COMPACTION_SUMMARY_PREFIX);
126
+ }
127
+
128
+ export function isBranchSummaryMessage(msg) {
129
+ return normalizeRole(msg?.role) === "user" && contentToText(msg.content).startsWith(PI_BRANCH_SUMMARY_PREFIX);
130
+ }
@@ -1,56 +1,11 @@
1
1
  // Renders OpenAI-format messages into the single text prompt DeepSeek web accepts.
2
- // Stateless: dumps the entire conversation history into a fresh DeepSeek chat.
2
+ // Two shapes: a *seed* (full history, starts a DeepSeek chat) and a *delta* (only what DeepSeek has not seen yet).
3
3
 
4
- import { BadRequestError } from "./errors.js";
4
+ import { contentToText, normalizeRole, parseToolArguments, toolCallArguments, toolCallName } from "./messages.js";
5
5
 
6
6
  const ASSISTANT_LABEL_WITH_TOOLS = "M3GAN";
7
7
  const ASSISTANT_LABEL_PLAIN = "ASSISTANT";
8
8
 
9
- export function normalizeRole(role) {
10
- return typeof role === "string" ? role.trim().toLowerCase() : "";
11
- }
12
-
13
- /**
14
- * Renders message content to text. Non-text parts are made visible as explicit placeholders instead of being dropped.
15
- */
16
- export function contentToText(content) {
17
- if (content === null || content === undefined) return "";
18
- if (typeof content === "string") return content;
19
- if (Array.isArray(content)) {
20
- return content
21
- .map((part) => {
22
- if (!part || typeof part !== "object") return "[unsupported content part]";
23
- if (part.type === "text") return String(part.text ?? "");
24
- if (part.type === "image_url" || part.type === "image") return "[image omitted: Black Mamba forwards text only]";
25
- return `[unsupported content part: ${String(part.type)}]`;
26
- })
27
- .join("\n");
28
- }
29
- throw new BadRequestError("Message 'content' must be a string or an array of content parts", "invalid_message");
30
- }
31
-
32
- /** Parses tool-call arguments; returns { value, raw } where raw is kept only when the string is not valid JSON. */
33
- export function parseToolArguments(args) {
34
- if (args === undefined || args === null) return { value: {} };
35
- if (typeof args === "object") return { value: args };
36
- if (typeof args === "string") {
37
- try {
38
- return { value: JSON.parse(args) };
39
- } catch {
40
- return { value: null, raw: args };
41
- }
42
- }
43
- return { value: null, raw: String(args) };
44
- }
45
-
46
- export function toolCallName(tc) {
47
- return tc?.function?.name ?? tc?.name ?? "";
48
- }
49
-
50
- export function toolCallArguments(tc) {
51
- return tc?.function?.arguments ?? tc?.arguments;
52
- }
53
-
54
9
  function toolNames(tools) {
55
10
  return (Array.isArray(tools) ? tools : []).map((t) => t.function?.name || t.name).filter(Boolean);
56
11
  }
@@ -132,7 +87,7 @@ Pi is connected to chat.deepseek and sends and listens to https://chat.deepseek.
132
87
  export function formatToolTurnDirective(_tools = []) {
133
88
  return `[Tool output received from user's machine. To take another action, output a DSML block and end turn. If complete, respond directly to the user.]
134
89
 
135
- REMINDER: When calling a tool, four tags must be there, in this order:
90
+ REQUIRED — Your next tool call MUST use this exact block. Copy the tag names verbatim. If any tag is wrong or missing, Pi will not run the tool.
136
91
 
137
92
  <|DSML|tool_calls>
138
93
  <|DSML|invoke name="TOOL">
@@ -142,7 +97,7 @@ REMINDER: When calling a tool, four tags must be there, in this order:
142
97
  }
143
98
 
144
99
  export const USER_TOOL_REMINDER =
145
- "[REMINDER: To invoke a tool, output a DSML block and end turn. If no tool is needed, reply directly.]";
100
+ "[REQUIRED: To run a tool, output one DSML block exactly as specified in the tool rules and end your turn. Wrong tags are ignored. If no tool is needed, reply in plain text only.]";
146
101
 
147
102
  function renderDsmlToolBlock(tc) {
148
103
  const name = toolCallName(tc);
@@ -158,6 +113,7 @@ function renderDsmlToolBlock(tc) {
158
113
  return `<|DSML|invoke name="${name}">\n${params}\n</|DSML|invoke>`;
159
114
  }
160
115
 
116
+ /** Assistant text followed by its tool calls rendered as native DSML blocks. */
161
117
  export function renderAssistantContent(msg) {
162
118
  let content = contentToText(msg.content);
163
119
  if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
@@ -185,9 +141,8 @@ function trailingDirective(lastMessage, tools) {
185
141
  }
186
142
 
187
143
  /**
188
- * Full-history prompt dumped to start a fresh DeepSeek chat session.
189
- * 1. With tools (e.g. Pi): M3GAN persona + tool definitions + history + trailing directive.
190
- * 2. Without tools (e.g. other apps): Plain transcript (SYSTEM: ..., USER: ..., ASSISTANT: ...).
144
+ * Full-history prompt used to start (or restart) a DeepSeek chat session.
145
+ * With tools: persona system prompt + labelled transcript. Without tools: plain labelled transcript.
191
146
  */
192
147
  export function renderSeedPrompt(messages, tools = []) {
193
148
  const hasTools = Array.isArray(tools) && tools.length > 0;
@@ -226,3 +181,42 @@ export function renderSeedPrompt(messages, tools = []) {
226
181
  if (directive) prompt += `\n${directive}`;
227
182
  return prompt;
228
183
  }
184
+
185
+ /**
186
+ * Prompt for one DeepSeek turn containing only the messages DeepSeek has not seen.
187
+ * @param {object[]} deltaMessages Messages after the last DeepSeek reply (never empty).
188
+ * @param {object[]} tools
189
+ * @param {{ systemUpdate?: string|null }} [options] New full system prompt when it changed since the parent node.
190
+ */
191
+ export function renderDeltaPrompt(deltaMessages, tools = [], { systemUpdate = null } = {}) {
192
+ if (!Array.isArray(deltaMessages) || deltaMessages.length === 0) {
193
+ throw new Error("renderDeltaPrompt requires at least one message");
194
+ }
195
+ const multiple = deltaMessages.length > 1;
196
+ const parts = [];
197
+
198
+ if (systemUpdate) {
199
+ parts.push(`<<System prompt update>>\nThe system prompt changed. The complete current system prompt follows:\n${systemUpdate}`);
200
+ }
201
+
202
+ deltaMessages.forEach((msg, i) => {
203
+ const role = normalizeRole(msg.role);
204
+ if (role === "tool") {
205
+ const label = multiple ? `Result for tool call ${i + 1}:` : null;
206
+ parts.push(renderToolResultBlock(msg, label));
207
+ } else if (role === "user") {
208
+ const text = contentToText(msg.content);
209
+ parts.push(multiple ? `USER: ${text}` : text);
210
+ } else if (role === "assistant") {
211
+ parts.push(`${ASSISTANT_LABEL_WITH_TOOLS}: ${renderAssistantContent(msg)}`);
212
+ } else if (role === "system" || role === "developer") {
213
+ parts.push(`<<System update>>\n${contentToText(msg.content)}`);
214
+ } else {
215
+ parts.push(`${role.toUpperCase()}: ${contentToText(msg.content)}`);
216
+ }
217
+ });
218
+
219
+ const directive = trailingDirective(deltaMessages[deltaMessages.length - 1], tools);
220
+ if (directive) parts.push(directive);
221
+ return parts.join("\n\n");
222
+ }
@@ -1,9 +1,29 @@
1
- // Validates an OpenAI-compatible chat completion request.
1
+ // Validates an OpenAI-compatible chat completion request and extracts the pi routing contract.
2
+ //
3
+ // Routing contract (sent by the pi extension in pi-extension/black-mamba.ts):
4
+ // x-session-id pi session id (also sent natively by pi-ai when sessionAffinityFormat = "openrouter")
5
+ // x-entry-id pi session entry id at which this request is made (the future parent of the assistant entry)
6
+ // x-parent-id entry id at which the previous *successful* assistant on this branch was requested
7
+ // x-parent-session-id when the session was created by /fork: the pi session id it was forked from
8
+ //
9
+ // Without x-session-id the request is treated as stateless. With x-session-id but without x-entry-id the request is
10
+ // rejected: there is no way to map it onto the DeepSeek tree precisely, and silently degrading would hide the problem.
2
11
 
3
- import { BadRequestError } from "./errors.js";
12
+ import { BadRequestError } from "../errors.js";
4
13
 
5
14
  const DEFAULT_MODEL = "deepseek-chat";
6
15
 
16
+ function getHeader(headers, name) {
17
+ if (!headers || typeof headers !== "object") return "";
18
+ const lower = name.toLowerCase();
19
+ for (const [key, value] of Object.entries(headers)) {
20
+ if (key.toLowerCase() !== lower) continue;
21
+ const str = Array.isArray(value) ? value[0] : value;
22
+ return typeof str === "string" ? str.trim() : str != null ? String(str).trim() : "";
23
+ }
24
+ return "";
25
+ }
26
+
7
27
  function validateToolCall(tc, index) {
8
28
  if (!tc || typeof tc !== "object") throw new BadRequestError(`tool_calls[${index}] must be an object`, "invalid_tool_call");
9
29
  const fn = tc.function;
@@ -59,10 +79,27 @@ function resolveThinking(body, lowerModel) {
59
79
  return lowerModel.includes("reasoner") || lowerModel.includes("r1") || lowerModel.includes("think");
60
80
  }
61
81
 
82
+ function extractRouting(headers) {
83
+ const sessionId = getHeader(headers, "x-session-id");
84
+ if (!sessionId) return null;
85
+
86
+ const entryId = getHeader(headers, "x-entry-id");
87
+ if (!entryId) {
88
+ throw new BadRequestError(
89
+ "x-session-id was sent without x-entry-id. Black Mamba maps pi's session tree onto DeepSeek precisely and needs the pi extension " +
90
+ "(pi-extension/black-mamba.ts -> ~/.pi/agent/extensions/) to send x-entry-id and x-parent-id. Remove x-session-id for stateless use.",
91
+ "missing_entry_id"
92
+ );
93
+ }
94
+ const parentEntryId = getHeader(headers, "x-parent-id") || null;
95
+ const parentSessionId = getHeader(headers, "x-parent-session-id") || null;
96
+ return Object.freeze({ sessionId, entryId, parentEntryId, parentSessionId });
97
+ }
98
+
62
99
  /**
63
- * @returns {{ messages: object[], tools: object[], model: string, stream: boolean, includeUsage: boolean, thinkingEnabled: boolean, searchEnabled: boolean }}
100
+ * @returns {{ messages: object[], tools: object[], model: string, stream: boolean, includeUsage: boolean, thinkingEnabled: boolean, searchEnabled: boolean, routing: {sessionId:string, entryId:string, parentEntryId:string|null, parentSessionId:string|null}|null }}
64
101
  */
65
- export function parseChatRequest(body) {
102
+ export function parseChatRequest(body, headers = {}) {
66
103
  if (!body || typeof body !== "object" || Array.isArray(body)) {
67
104
  throw new BadRequestError("Request body must be a JSON object", "invalid_body");
68
105
  }
@@ -82,5 +119,6 @@ export function parseChatRequest(body) {
82
119
  includeUsage: Boolean(body.stream_options && body.stream_options.include_usage),
83
120
  thinkingEnabled: resolveThinking(body, lowerModel),
84
121
  searchEnabled: lowerModel.includes("search") || lowerModel.endsWith(":online"),
122
+ routing: extractRouting(headers),
85
123
  });
86
124
  }
@@ -1,6 +1,6 @@
1
1
  // OpenAI-compatible response payloads (JSON and SSE chunks) and usage accounting.
2
2
 
3
- import { estimateTokenCount } from "./tokens.js";
3
+ import { estimateTokenCount } from "../utils/tokens.js";
4
4
 
5
5
  export function createCompletionMetadata(nowMs = Date.now()) {
6
6
  return { completionId: `chatcmpl-${nowMs}`, created: Math.floor(nowMs / 1000) };
@@ -34,11 +34,27 @@ export function buildUsageStats({ prompt, reply, turnTokens = 0, cachedTokens =
34
34
  return usage;
35
35
  }
36
36
 
37
+ /** Extra, non-OpenAI fields that make every mapping decision visible to the caller. */
38
+ export function buildMappingInfo({ sessionId, deepseekSessionId, messageId, parentMessageId, strategy, reason, deltaMessageCount }) {
39
+ const info = {
40
+ chat_session_id: sessionId,
41
+ deepseek_session_id: deepseekSessionId ?? null,
42
+ deepseek_message_id: messageId !== null && messageId !== undefined ? String(messageId) : null,
43
+ black_mamba: {
44
+ strategy,
45
+ reason: reason ?? null,
46
+ deepseek_parent_message_id: parentMessageId !== null && parentMessageId !== undefined ? String(parentMessageId) : null,
47
+ },
48
+ };
49
+ if (deltaMessageCount !== undefined) info.black_mamba.delta_messages = deltaMessageCount;
50
+ return info;
51
+ }
52
+
37
53
  function baseChunk({ completionId, created, model }, choices, extra = {}) {
38
54
  return { id: completionId, object: "chat.completion.chunk", created, model, choices, ...extra };
39
55
  }
40
56
 
41
- export function formatCompletionResponse({ completionId, created, model, content, toolCalls = [], usage }) {
57
+ export function formatCompletionResponse({ completionId, created, model, content, toolCalls = [], usage, mapping }) {
42
58
  const hasTools = toolCalls.length > 0;
43
59
  const message = { role: "assistant", content: hasTools ? content || null : content };
44
60
  if (hasTools) message.tool_calls = toolCalls;
@@ -49,6 +65,7 @@ export function formatCompletionResponse({ completionId, created, model, content
49
65
  model,
50
66
  choices: [{ index: 0, message, finish_reason: hasTools ? "tool_calls" : "stop" }],
51
67
  usage,
68
+ ...(mapping ?? {}),
52
69
  };
53
70
  }
54
71
 
@@ -80,8 +97,8 @@ export function formatStreamToolCallArgsEvent(meta, { toolCallIndex, argumentsCh
80
97
  return sseFrame(baseChunk(meta, [{ index: 0, delta: { tool_calls: [{ index: toolCallIndex, function: { arguments: argumentsChunk } }] }, finish_reason: null }]));
81
98
  }
82
99
 
83
- export function formatStreamFinishEvent(meta, { finishReason = "stop" } = {}) {
84
- return sseFrame(baseChunk(meta, [{ index: 0, delta: {}, finish_reason: finishReason }]));
100
+ export function formatStreamFinishEvent(meta, { finishReason = "stop", mapping } = {}) {
101
+ return sseFrame(baseChunk(meta, [{ index: 0, delta: {}, finish_reason: finishReason }], mapping ?? {}));
85
102
  }
86
103
 
87
104
  export function formatStreamUsageEvent(meta, usage) {