@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.
@@ -0,0 +1,214 @@
1
+ // POST /v1/chat/completions orchestration: parse -> plan -> DeepSeek turn -> stream back -> record node.
2
+
3
+ import { CONFIG } from "../config.js";
4
+ import { UpstreamContextRejectedError, UpstreamError } from "../errors.js";
5
+ import { createLogger } from "../logger.js";
6
+ import { consumeDeepSeekSseStream } from "../deepseek/sse.js";
7
+ import { assistantFingerprint } from "../pi/messages.js";
8
+ import { parseChatRequest } from "../pi/request.js";
9
+ import {
10
+ buildMappingInfo,
11
+ buildUsageStats,
12
+ createCompletionMetadata,
13
+ formatCompletionResponse,
14
+ formatStreamDeltaEvent,
15
+ formatStreamFinishEvent,
16
+ formatStreamReasoningEvent,
17
+ formatStreamRoleEvent,
18
+ formatStreamToolCallArgsEvent,
19
+ formatStreamToolCallStartEvent,
20
+ formatStreamUsageEvent,
21
+ } from "../pi/response.js";
22
+ import { createToolCallStreamProcessor, parseToolCallsFromText } from "../pi/tool-calls.js";
23
+ import { STRATEGY, planCompletion, reseedAfterUpstreamRejection } from "../session/planner.js";
24
+ import { estimateTokenCount } from "../utils/tokens.js";
25
+ import { readJsonBody, sendJson, startSse, writeSse } from "./http.js";
26
+
27
+ const STRATEGY_HEADER = "x-black-mamba-strategy";
28
+
29
+ function describePlan(plan, routing) {
30
+ const pi = routing ? `${routing.sessionId}/${routing.entryId}` : "stateless";
31
+ const parent = plan.parentNode ? plan.parentNode.piEntryId : "-";
32
+ const ds = plan.deepseek.sessionId ? `${plan.deepseek.sessionId}:${plan.deepseek.parentMessageId ?? "root"}` : "new-chat";
33
+ return `pi=${pi} parent=${parent} strategy=${plan.strategy}${plan.reason ? ` reason=${plan.reason}` : ""} ds=${ds} delta=${plan.deltaMessageCount} prompt_chars=${plan.prompt.length}`;
34
+ }
35
+
36
+ /**
37
+ * @param {object} deps
38
+ * @param {ReturnType<import("../deepseek/client.js").createDeepSeekClient>} deps.deepseek
39
+ * @param {import("../session/graph.js").SessionGraph} deps.graph
40
+ * @param {ReturnType<import("./pacing.js").createPacer>} deps.pacer
41
+ */
42
+ export function createCompletionHandler({ deepseek, graph, pacer, logger = createLogger("Mapping"), keepAliveIntervalMs = CONFIG.server.keepAliveIntervalMs }) {
43
+ async function openStream(plan, request, signal) {
44
+ const dsSessionId = plan.deepseek.sessionId ?? (await deepseek.createChatSession());
45
+ const { streamBody } = await deepseek.openCompletionStream({
46
+ chatSessionId: dsSessionId,
47
+ parentMessageId: plan.deepseek.parentMessageId,
48
+ prompt: plan.prompt,
49
+ thinkingEnabled: request.thinkingEnabled,
50
+ searchEnabled: request.searchEnabled,
51
+ signal,
52
+ });
53
+ return { dsSessionId, streamBody };
54
+ }
55
+
56
+ async function openWithExplicitRecovery(plan, request, signal) {
57
+ try {
58
+ return { plan, ...(await openStream(plan, request, signal)) };
59
+ } catch (err) {
60
+ if (!(err instanceof UpstreamContextRejectedError) || plan.strategy !== STRATEGY.DELTA) throw err;
61
+ logger.warn(`DeepSeek rejected chat ${plan.deepseek.sessionId} parent ${plan.deepseek.parentMessageId}: ${err.message}. Re-seeding this branch into a fresh chat.`);
62
+ const reseeded = reseedAfterUpstreamRejection(request, plan, err.details);
63
+ logger.info(describePlan(reseeded, request.routing));
64
+ return { plan: reseeded, ...(await openStream(reseeded, request, signal)) };
65
+ }
66
+ }
67
+
68
+ function resolveTurnTokens(result, plan, replyText) {
69
+ if (result.turnTokens > 0) return { turnTokens: result.turnTokens, source: "deepseek" };
70
+ return { turnTokens: estimateTokenCount(plan.prompt) + estimateTokenCount(replyText), source: "estimated" };
71
+ }
72
+
73
+ async function recordNode({ plan, dsSessionId, result, text, toolCalls, turnTokens }) {
74
+ if (!plan.record) {
75
+ if (result.messageId === null || result.messageId === undefined) {
76
+ logger.warn("DeepSeek stream ended without response_message_id (stateless request; nothing to record).");
77
+ }
78
+ return null;
79
+ }
80
+ if (result.messageId === null || result.messageId === undefined) {
81
+ throw new UpstreamError("DeepSeek did not return a response_message_id; this turn cannot be mapped onto the session tree and was not recorded.", {
82
+ code: "deepseek_missing_message_id",
83
+ details: { piEntryId: plan.record.piEntryId, deepseekSessionId: dsSessionId },
84
+ });
85
+ }
86
+ const node = {
87
+ piEntryId: plan.record.piEntryId,
88
+ piParentEntryId: plan.record.piParentEntryId,
89
+ strategy: plan.strategy,
90
+ reason: plan.reason,
91
+ deepseek: { sessionId: dsSessionId, messageId: result.messageId, parentMessageId: plan.deepseek.parentMessageId },
92
+ context: { ...plan.record.context, assistant: assistantFingerprint({ text, toolCalls }) },
93
+ usage: { turnTokens, cachedTokens: plan.cachedTokens },
94
+ createdAt: Date.now(),
95
+ };
96
+ await graph.putNode(plan.session, node);
97
+ logger.info(`recorded pi=${plan.session.piSessionId}/${node.piEntryId} -> ds=${dsSessionId}:${result.messageId} (parent ${node.deepseek.parentMessageId ?? "root"}, ${turnTokens} turn tokens)`);
98
+ return node;
99
+ }
100
+
101
+ function mappingInfo({ request, plan, dsSessionId, messageId, usageSource }) {
102
+ const info = buildMappingInfo({
103
+ sessionId: request.routing?.sessionId ?? dsSessionId,
104
+ deepseekSessionId: dsSessionId,
105
+ messageId,
106
+ parentMessageId: plan.deepseek.parentMessageId,
107
+ strategy: plan.strategy,
108
+ reason: plan.reason,
109
+ deltaMessageCount: plan.deltaMessageCount,
110
+ });
111
+ info.black_mamba.usage_source = usageSource;
112
+ return info;
113
+ }
114
+
115
+ async function respondJson({ res, request, plan, dsSessionId, streamBody, meta }) {
116
+ const result = await consumeDeepSeekSseStream(streamBody);
117
+ let text = result.reply;
118
+ let toolCalls = [];
119
+ if (request.tools.length > 0) {
120
+ const parsed = parseToolCallsFromText(result.reply);
121
+ text = parsed.cleanText;
122
+ toolCalls = parsed.toolCalls;
123
+ }
124
+ const { turnTokens, source } = resolveTurnTokens(result, plan, result.reply);
125
+ const fingerprintCalls = toolCalls.map((tc) => ({ name: tc.function.name, argumentsString: tc.function.arguments }));
126
+ await recordNode({ plan, dsSessionId, result, text, toolCalls: fingerprintCalls, turnTokens });
127
+
128
+ const usage = buildUsageStats({ prompt: plan.prompt, reply: result.reply, turnTokens, cachedTokens: plan.cachedTokens });
129
+ const payload = formatCompletionResponse({
130
+ ...meta,
131
+ model: request.model,
132
+ content: text,
133
+ toolCalls,
134
+ usage,
135
+ mapping: mappingInfo({ request, plan, dsSessionId, messageId: result.messageId, usageSource: source }),
136
+ });
137
+ sendJson(res, 200, payload, { [STRATEGY_HEADER]: plan.strategy });
138
+ }
139
+
140
+ async function respondStream({ req, res, request, plan, dsSessionId, streamBody, meta }) {
141
+ startSse(res, req, { [STRATEGY_HEADER]: plan.strategy });
142
+ writeSse(res, formatStreamRoleEvent(meta));
143
+ const keepAlive = setInterval(() => writeSse(res, ": keep-alive\n\n"), keepAliveIntervalMs);
144
+ keepAlive.unref?.();
145
+
146
+ let emittedText = "";
147
+ const emittedToolCalls = [];
148
+ const emitText = (content) => {
149
+ emittedText += content;
150
+ writeSse(res, formatStreamDeltaEvent(meta, content));
151
+ };
152
+ const emitToolCall = ({ index, id, name, argumentsString }) => {
153
+ emittedToolCalls.push({ name, argumentsString });
154
+ writeSse(res, formatStreamToolCallStartEvent(meta, { toolCallIndex: index, id, name }));
155
+ writeSse(res, formatStreamToolCallArgsEvent(meta, { toolCallIndex: index, argumentsChunk: argumentsString }));
156
+ };
157
+ const processor = request.tools.length > 0 ? createToolCallStreamProcessor({ onTextDelta: emitText, onToolCall: emitToolCall }) : null;
158
+
159
+ let result;
160
+ try {
161
+ result = await consumeDeepSeekSseStream(streamBody, (chunk) => {
162
+ if (chunk.isThinking) writeSse(res, formatStreamReasoningEvent(meta, chunk.text));
163
+ else if (processor) processor.processChunk(chunk.text);
164
+ else emitText(chunk.text);
165
+ });
166
+ processor?.flush();
167
+ } finally {
168
+ clearInterval(keepAlive);
169
+ }
170
+
171
+ const { turnTokens, source } = resolveTurnTokens(result, plan, result.reply);
172
+ await recordNode({ plan, dsSessionId, result, text: emittedText, toolCalls: emittedToolCalls, turnTokens });
173
+
174
+ writeSse(
175
+ res,
176
+ formatStreamFinishEvent(meta, {
177
+ finishReason: emittedToolCalls.length > 0 ? "tool_calls" : "stop",
178
+ mapping: mappingInfo({ request, plan, dsSessionId, messageId: result.messageId, usageSource: source }),
179
+ })
180
+ );
181
+ if (request.includeUsage) {
182
+ writeSse(res, formatStreamUsageEvent(meta, buildUsageStats({ prompt: plan.prompt, reply: result.reply, turnTokens, cachedTokens: plan.cachedTokens })));
183
+ }
184
+ writeSse(res, "data: [DONE]\n\n");
185
+ res.end();
186
+ }
187
+
188
+ return async function handleChatCompletions(req, res) {
189
+ const body = await readJsonBody(req);
190
+ const request = parseChatRequest(body, req.headers);
191
+ const initialPlan = await planCompletion({ request, graph, logger });
192
+ logger.info(describePlan(initialPlan, request.routing));
193
+
194
+ // Abort the DeepSeek turn if pi disconnects mid-stream (pi marks its assistant message as aborted and drops it).
195
+ const abortController = new AbortController();
196
+ let finished = false;
197
+ if (typeof res.on === "function") {
198
+ res.on("close", () => {
199
+ if (!finished && !res.writableFinished) abortController.abort();
200
+ });
201
+ }
202
+
203
+ await pacer.apply();
204
+ try {
205
+ const { plan, dsSessionId, streamBody } = await openWithExplicitRecovery(initialPlan, request, abortController.signal);
206
+ const meta = { ...createCompletionMetadata(), model: request.model };
207
+ if (request.stream) await respondStream({ req, res, request, plan, dsSessionId, streamBody, meta });
208
+ else await respondJson({ res, request, plan, dsSessionId, streamBody, meta });
209
+ } finally {
210
+ finished = true;
211
+ pacer.recordCompletionFinished();
212
+ }
213
+ };
214
+ }
@@ -1,18 +1,17 @@
1
1
  // HTTP plumbing: routing, body parsing, API-key validation, JSON/SSE writers, error rendering.
2
2
 
3
3
  import crypto from "node:crypto";
4
- import { CONFIG } from "./config.js";
5
- import { BadRequestError, HttpError } from "./errors.js";
6
- import { createLogger } from "./logger.js";
7
- import { formatStreamErrorEvent } from "./response.js";
4
+ import { CONFIG } from "../config.js";
5
+ import { BadRequestError, HttpError } from "../errors.js";
6
+ import { createLogger } from "../logger.js";
7
+ import { formatStreamErrorEvent } from "../pi/response.js";
8
8
 
9
9
  const SUPPORTED_MODEL_IDS = Object.freeze(["deepseek-chat", "deepseek-reasoner", "deepseek-chat-search", "deepseek-reasoner-search"]);
10
- const CORS_HEADERS = "Content-Type, Authorization";
10
+ const CORS_HEADERS = "Content-Type, Authorization, X-Session-Id, X-Entry-Id, X-Parent-Id, X-Parent-Session-Id";
11
11
 
12
12
  export function validateApiKey(req, apiKey) {
13
- if (!apiKey) return true; // if no apiKey configured, allow all
14
13
  const authHeader = req.headers["authorization"];
15
- if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
14
+ if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ") || !apiKey) return false;
16
15
  const expected = Buffer.from(apiKey, "utf8");
17
16
  const provided = Buffer.from(authHeader.slice("Bearer ".length), "utf8");
18
17
  return expected.length === provided.length && crypto.timingSafeEqual(expected, provided);
@@ -69,7 +68,7 @@ export function errorBody(err, sanitize) {
69
68
  return { message: sanitize(err instanceof Error ? err.message : "Internal server error"), code: "internal_error" };
70
69
  }
71
70
 
72
- async function routeAuthed(req, res, pathname, { handleChatCompletions }) {
71
+ async function routeAuthed(req, res, pathname, { handleChatCompletions, graph }) {
73
72
  if (req.method === "GET" && pathname === "/v1/models") {
74
73
  const created = Math.floor(Date.now() / 1000);
75
74
  sendJson(res, 200, { object: "list", data: SUPPORTED_MODEL_IDS.map((id) => ({ id, object: "model", created, owned_by: "deepseek" })) });
@@ -80,22 +79,41 @@ async function routeAuthed(req, res, pathname, { handleChatCompletions }) {
80
79
  return;
81
80
  }
82
81
 
82
+ const sessionMatch = pathname.match(/^\/v1\/sessions\/([^/]+)$/);
83
+ if (sessionMatch) {
84
+ const piSessionId = decodeURIComponent(sessionMatch[1]);
85
+ if (req.method === "GET") {
86
+ const session = await graph.getSession(piSessionId);
87
+ if (!session) {
88
+ sendJson(res, 404, { error: { message: `No mapping for pi session ${piSessionId}`, code: "session_not_found" } });
89
+ } else {
90
+ sendJson(res, 200, graph.describeSession(session));
91
+ }
92
+ return;
93
+ }
94
+ if (req.method === "DELETE") {
95
+ const existed = await graph.deleteSession(piSessionId);
96
+ sendJson(res, 200, { deleted: existed, piSessionId, note: "The next request for this session will seed a fresh DeepSeek chat." });
97
+ return;
98
+ }
99
+ }
100
+
83
101
  sendJson(res, 404, { error: { message: "Route not found", code: "not_found" } });
84
102
  }
85
103
 
86
104
  /**
87
105
  * @param {object} deps
88
- * @param {(req, res) => Promise<void>} deps.handleChatCompletions
106
+ * @param {(req, res) => Promise<void>} [deps.handleChatCompletions]
89
107
  * @param {() => object} [deps.getStatus]
108
+ * @param {import("../session/graph.js").SessionGraph} [deps.graph]
90
109
  * @param {(err: unknown) => string} deps.sanitize
91
- * @param {string} [deps.apiKey]
92
- * @param {object} [deps.logger]
110
+ * @param {(authorizationHeader: string) => object|null} [deps.resolveAccount]
93
111
  */
94
- export function createRequestHandler({ handleChatCompletions, getStatus = () => ({ status: "ok" }), sanitize, apiKey = CONFIG.apiKey, logger = createLogger("Server") }) {
112
+ export function createRequestHandler({ handleChatCompletions, getStatus, graph, sanitize, apiKey = "", logger = createLogger("Server"), resolveAccount = null }) {
95
113
  return async function handleRequest(req, res) {
96
114
  res.setHeader("Access-Control-Allow-Origin", "*");
97
115
  res.setHeader("Access-Control-Allow-Headers", CORS_HEADERS);
98
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
116
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
99
117
 
100
118
  if (req.method === "OPTIONS") {
101
119
  res.writeHead(204);
@@ -104,8 +122,24 @@ export function createRequestHandler({ handleChatCompletions, getStatus = () =>
104
122
  }
105
123
 
106
124
  const pathname = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`).pathname;
125
+ let requestLog = logger;
107
126
 
108
127
  try {
128
+ if (resolveAccount) {
129
+ const account = resolveAccount(req.headers.authorization);
130
+ if (!account) {
131
+ sendJson(res, 401, { error: { message: "Invalid API key", code: "invalid_api_key" } });
132
+ return;
133
+ }
134
+ requestLog = account.logger;
135
+ if (req.method === "GET" && pathname === "/") {
136
+ sendJson(res, 200, account.auth.statusSummary());
137
+ return;
138
+ }
139
+ await routeAuthed(req, res, pathname, { handleChatCompletions: account.handleChatCompletions, graph: account.graph });
140
+ return;
141
+ }
142
+
109
143
  if (req.method === "GET" && pathname === "/") {
110
144
  sendJson(res, 200, getStatus());
111
145
  return;
@@ -114,12 +148,12 @@ export function createRequestHandler({ handleChatCompletions, getStatus = () =>
114
148
  sendJson(res, 401, { error: { message: "Invalid API key", code: "invalid_api_key" } });
115
149
  return;
116
150
  }
117
- await routeAuthed(req, res, pathname, { handleChatCompletions });
151
+ await routeAuthed(req, res, pathname, { handleChatCompletions, graph });
118
152
  } catch (err) {
119
153
  const statusCode = err instanceof HttpError ? err.statusCode : 500;
120
154
  const body = errorBody(err, sanitize);
121
- if (statusCode >= 500) logger.error(`Request error (${req.method} ${pathname}):`, body.message, body.details ? JSON.stringify(body.details) : "");
122
- else logger.warn(`Request rejected (${req.method} ${pathname}) ${statusCode}:`, body.message);
155
+ if (statusCode >= 500) requestLog.error(`Request error (${req.method} ${pathname}):`, body.message, body.details ? JSON.stringify(body.details) : "");
156
+ else requestLog.warn(`Request rejected (${req.method} ${pathname}) ${statusCode}:`, body.message);
123
157
 
124
158
  if (!res.headersSent) {
125
159
  sendJson(res, statusCode, { error: body });
@@ -1,6 +1,6 @@
1
1
  // Human-like pacing between consecutive DeepSeek turns (DeepSeek web challenges rapid-fire automation).
2
2
 
3
- import { CONFIG } from "./config.js";
3
+ import { CONFIG } from "../config.js";
4
4
 
5
5
  export function calculateTurnPacingDelay({ lastFinishedAt, now = Date.now(), minDelayMs = CONFIG.pacing.minTurnDelayMs, maxDelayMs = CONFIG.pacing.maxTurnDelayMs, randomFn = Math.random }) {
6
6
  if (!lastFinishedAt || lastFinishedAt <= 0) return 0;
@@ -0,0 +1,182 @@
1
+ // Session graph: the mapping between pi's session tree and DeepSeek's chat trees.
2
+ //
3
+ // One Session per pi session id. One Node per LLM request pi made, keyed by the pi entry id at which the
4
+ // request was made (x-entry-id). Each node records which DeepSeek chat session and message it produced and a
5
+ // fingerprint of the context that produced it. DeepSeek message ids are never used as keys because they
6
+ // restart in every DeepSeek chat session.
7
+
8
+ import { CONFIG } from "../config.js";
9
+ import { createLogger } from "../logger.js";
10
+ import { SESSION_SCHEMA_VERSION, SessionStore } from "./store.js";
11
+
12
+ /**
13
+ * @typedef {object} Node
14
+ * @property {string} piEntryId pi entry id at which the request was made
15
+ * @property {string|null} piParentEntryId pi entry id of the node this request attached to (null for seeds)
16
+ * @property {string} strategy how the node was created (root_seed | delta | reseed_* ...)
17
+ * @property {string|null} reason
18
+ * @property {{ sessionId: string, messageId: number|string, parentMessageId: number|string|null }} deepseek
19
+ * @property {{ messageCount: number, systemHash: string, prefixHash: string, assistant: { textHash: string, toolCalls: {name:string, argsHash:string}[] } }} context
20
+ * @property {{ turnTokens: number, cachedTokens: number }} usage
21
+ * @property {number} createdAt
22
+ */
23
+
24
+ export class SessionGraph {
25
+ constructor({ sessionsDir = CONFIG.sessions.dir, ttlMs = CONFIG.sessions.ttlMs, maxEntries = CONFIG.sessions.maxEntries, logger = createLogger("SessionGraph") } = {}) {
26
+ this.logger = logger;
27
+ this.ttlMs = ttlMs;
28
+ this.maxEntries = maxEntries;
29
+ this.store = new SessionStore({ dir: sessionsDir, ttlMs, logger });
30
+ this.sessions = new Map(); // piSessionId -> Session
31
+ this.cleanupTimer = null;
32
+ }
33
+
34
+ async init() {
35
+ await this.store.init();
36
+ await this.pruneExpired();
37
+ }
38
+
39
+ async getSession(piSessionId) {
40
+ if (!piSessionId) return null;
41
+ const key = String(piSessionId);
42
+ let session = this.sessions.get(key);
43
+ if (!session) {
44
+ session = await this.store.read(key);
45
+ if (session) this.sessions.set(key, session);
46
+ }
47
+ if (session) session.lastAccessedAt = Date.now();
48
+ return session ?? null;
49
+ }
50
+
51
+ async createSession(piSessionId) {
52
+ const key = String(piSessionId);
53
+ if (!key) throw new Error("piSessionId is required");
54
+ const existing = await this.getSession(key);
55
+ if (existing) return existing;
56
+
57
+ const now = Date.now();
58
+ const session = {
59
+ schemaVersion: SESSION_SCHEMA_VERSION,
60
+ piSessionId: key,
61
+ createdAt: now,
62
+ lastAccessedAt: now,
63
+ forkedFrom: null,
64
+ nodes: {},
65
+ };
66
+ this.sessions.set(key, session);
67
+ await this.store.write(key, session);
68
+ return session;
69
+ }
70
+
71
+ getNode(session, piEntryId) {
72
+ if (!session || piEntryId === null || piEntryId === undefined) return null;
73
+ return session.nodes[String(piEntryId)] ?? null;
74
+ }
75
+
76
+ nodeCount(session) {
77
+ return session ? Object.keys(session.nodes).length : 0;
78
+ }
79
+
80
+ /** Records (or replaces) the node for a pi entry id and persists the session. */
81
+ async putNode(session, node) {
82
+ if (!node?.piEntryId) throw new Error("node.piEntryId is required");
83
+ const key = String(node.piEntryId);
84
+ if (session.nodes[key]) {
85
+ this.logger.warn(`Node ${key} in session ${session.piSessionId} already existed and was replaced (pi re-used a leaf entry id).`);
86
+ }
87
+ session.nodes[key] = node;
88
+ session.lastAccessedAt = Date.now();
89
+ await this.store.write(session.piSessionId, session);
90
+ return node;
91
+ }
92
+
93
+ /** Copies every node of `source` into `target` (used when pi forks a session; entry ids are preserved by pi). */
94
+ async adoptNodes(target, source) {
95
+ for (const [key, node] of Object.entries(source.nodes)) {
96
+ if (!target.nodes[key]) target.nodes[key] = node;
97
+ }
98
+ target.forkedFrom = { piSessionId: source.piSessionId, adoptedAt: Date.now() };
99
+ target.lastAccessedAt = Date.now();
100
+ await this.store.write(target.piSessionId, target);
101
+ }
102
+
103
+ /**
104
+ * Tokens DeepSeek already holds in context when attaching to `node`: the node's own turn plus every
105
+ * ancestor turn in the same DeepSeek chat session.
106
+ */
107
+ branchCachedTokens(session, node) {
108
+ let total = 0;
109
+ const visited = new Set();
110
+ let current = node;
111
+ while (current && !visited.has(current.piEntryId)) {
112
+ visited.add(current.piEntryId);
113
+ if (current.deepseek.sessionId !== node.deepseek.sessionId) break;
114
+ total += current.usage?.turnTokens || 0;
115
+ current = current.piParentEntryId ? this.getNode(session, current.piParentEntryId) : null;
116
+ }
117
+ return total;
118
+ }
119
+
120
+ async deleteSession(piSessionId) {
121
+ const key = String(piSessionId);
122
+ const existed = this.sessions.delete(key);
123
+ await this.store.remove(key);
124
+ return existed;
125
+ }
126
+
127
+ async pruneExpired(now = Date.now()) {
128
+ for (const [key, session] of this.sessions.entries()) {
129
+ if (now - (session.lastAccessedAt || 0) > this.ttlMs) {
130
+ this.sessions.delete(key);
131
+ await this.store.remove(key);
132
+ }
133
+ }
134
+ await this.store.pruneOlderThan(now);
135
+
136
+ if (this.sessions.size > this.maxEntries) {
137
+ const sorted = Array.from(this.sessions.entries()).sort(([, a], [, b]) => (a.lastAccessedAt || 0) - (b.lastAccessedAt || 0));
138
+ for (const [key] of sorted.slice(0, this.sessions.size - this.maxEntries)) {
139
+ // Evict from memory only; the file stays until its TTL so the session can be reloaded.
140
+ this.sessions.delete(key);
141
+ }
142
+ }
143
+ }
144
+
145
+ startPeriodicPrune(intervalMs = 60 * 60 * 1000) {
146
+ this.stopPeriodicPrune();
147
+ this.cleanupTimer = setInterval(() => {
148
+ this.pruneExpired().catch((err) => this.logger.warn("Periodic prune error:", err.message));
149
+ }, intervalMs);
150
+ this.cleanupTimer.unref?.();
151
+ }
152
+
153
+ stopPeriodicPrune() {
154
+ if (this.cleanupTimer) {
155
+ clearInterval(this.cleanupTimer);
156
+ this.cleanupTimer = null;
157
+ }
158
+ }
159
+
160
+ /** Read-only view for the inspection endpoint. */
161
+ describeSession(session) {
162
+ return {
163
+ piSessionId: session.piSessionId,
164
+ createdAt: new Date(session.createdAt).toISOString(),
165
+ lastAccessedAt: new Date(session.lastAccessedAt).toISOString(),
166
+ forkedFrom: session.forkedFrom,
167
+ nodeCount: this.nodeCount(session),
168
+ nodes: Object.values(session.nodes)
169
+ .sort((a, b) => a.createdAt - b.createdAt)
170
+ .map((n) => ({
171
+ piEntryId: n.piEntryId,
172
+ piParentEntryId: n.piParentEntryId,
173
+ strategy: n.strategy,
174
+ reason: n.reason,
175
+ deepseek: n.deepseek,
176
+ messageCount: n.context.messageCount,
177
+ turnTokens: n.usage.turnTokens,
178
+ createdAt: new Date(n.createdAt).toISOString(),
179
+ })),
180
+ };
181
+ }
182
+ }