@mathew-macharia/black-mamba 1.0.3 → 1.0.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.
@@ -1,182 +0,0 @@
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
- }
@@ -1,177 +0,0 @@
1
- // Mapping planner: decides, for one pi request, exactly which DeepSeek chat/parent to attach to and what text to
2
- // send. Every outcome is a named strategy with a reason. There is no implicit fallback: either the parent node
3
- // exists and the context pi sent verifiably extends it (-> delta), or DeepSeek has no matching context and a
4
- // fresh chat is seeded with the full history (-> seed). Both outcomes reproduce pi's context exactly.
5
- //
6
- // Strategies
7
- // stateless_seed no x-session-id: full history into a throwaway DeepSeek chat, nothing recorded
8
- // root_seed stateful request with no usable parent node (first turn, unknown session, /tree to root)
9
- // delta parent node found and verified: send only the messages DeepSeek has not seen
10
- // reseed_context_diverged parent node found but pi's context no longer extends it (compaction, context_edit, ...)
11
- // reseed_upstream_rejected DeepSeek rejected the stored chat/parent (chat deleted or expired on their side)
12
-
13
- import { BadRequestError, HttpError } from "../errors.js";
14
- import {
15
- assistantFingerprintFromMessage,
16
- contentToText,
17
- fingerprintsEqual,
18
- hasLeadingSystem,
19
- isCompactionSummaryMessage,
20
- normalizeRole,
21
- prefixHash,
22
- systemHash,
23
- } from "../pi/messages.js";
24
- import { renderDeltaPrompt, renderSeedPrompt, renderSystemPrompt } from "../pi/prompt.js";
25
-
26
- export const STRATEGY = Object.freeze({
27
- STATELESS_SEED: "stateless_seed",
28
- ROOT_SEED: "root_seed",
29
- DELTA: "delta",
30
- RESEED_DIVERGED: "reseed_context_diverged",
31
- RESEED_UPSTREAM: "reseed_upstream_rejected",
32
- });
33
-
34
- function contextFingerprint(messages) {
35
- return { messageCount: messages.length, systemHash: systemHash(messages), prefixHash: prefixHash(messages) };
36
- }
37
-
38
- /**
39
- * Checks that `messages` is exactly "the parent's request context + the parent's reply + new messages".
40
- * @returns {{ ok: true } | { ok: false, reason: string, details: object }}
41
- */
42
- export function verifyContextExtendsNode(messages, node) {
43
- const parentCount = node.context.messageCount;
44
- const firstBody = hasLeadingSystem(messages) ? 1 : 0;
45
- // pi replaces the history with a summary message on compaction; the context then no longer extends any earlier node.
46
- const startsWithCompaction = messages.length > firstBody && isCompactionSummaryMessage(messages[firstBody]);
47
-
48
- if (messages.length <= parentCount) {
49
- return {
50
- ok: false,
51
- reason: startsWithCompaction ? "compaction" : "context_shorter_than_parent",
52
- details: { messageCount: messages.length, parentMessageCount: parentCount },
53
- };
54
- }
55
- const expectedPrefix = prefixHash(messages, parentCount);
56
- if (expectedPrefix !== node.context.prefixHash) {
57
- return {
58
- ok: false,
59
- reason: startsWithCompaction ? "compaction" : "context_prefix_changed",
60
- details: { parentMessageCount: parentCount, expectedPrefixHash: node.context.prefixHash, actualPrefixHash: expectedPrefix },
61
- };
62
- }
63
- const reply = messages[parentCount];
64
- if (normalizeRole(reply.role) !== "assistant") {
65
- return { ok: false, reason: "parent_reply_missing", details: { indexOfReply: parentCount, roleFound: normalizeRole(reply.role) } };
66
- }
67
- if (!fingerprintsEqual(assistantFingerprintFromMessage(reply), node.context.assistant)) {
68
- return { ok: false, reason: "parent_reply_modified", details: { indexOfReply: parentCount } };
69
- }
70
- return { ok: true };
71
- }
72
-
73
- function seedPlan({ request, strategy, reason, details = null, session = null, parentNode = null }) {
74
- const { messages, tools, routing } = request;
75
- return Object.freeze({
76
- strategy,
77
- reason,
78
- details,
79
- session,
80
- parentNode,
81
- prompt: renderSeedPrompt(messages, tools),
82
- deepseek: { sessionId: null, parentMessageId: null }, // null sessionId => the handler creates a fresh DeepSeek chat
83
- cachedTokens: 0,
84
- deltaMessageCount: messages.length,
85
- record: routing
86
- ? { piEntryId: routing.entryId, piParentEntryId: routing.parentEntryId, context: contextFingerprint(messages) }
87
- : null,
88
- });
89
- }
90
-
91
- /** Resolves the parent node, adopting a forked session's graph when pi tells us where it was forked from. */
92
- async function resolveParent({ graph, routing, logger }) {
93
- let session = await graph.getSession(routing.sessionId);
94
- let parentNode = null;
95
- let adoptedFrom = null;
96
-
97
- if (routing.parentEntryId) {
98
- parentNode = graph.getNode(session, routing.parentEntryId);
99
-
100
- if (!parentNode && routing.parentSessionId && routing.parentSessionId !== routing.sessionId) {
101
- const parentSession = await graph.getSession(routing.parentSessionId);
102
- const candidate = graph.getNode(parentSession, routing.parentEntryId);
103
- if (candidate) {
104
- session = session ?? (await graph.createSession(routing.sessionId));
105
- await graph.adoptNodes(session, parentSession);
106
- parentNode = graph.getNode(session, routing.parentEntryId);
107
- adoptedFrom = routing.parentSessionId;
108
- logger.info(`Fork detected: session ${routing.sessionId} adopted ${graph.nodeCount(parentSession)} node(s) from ${routing.parentSessionId}.`);
109
- }
110
- }
111
- }
112
-
113
- if (!session) session = await graph.createSession(routing.sessionId);
114
- return { session, parentNode, adoptedFrom };
115
- }
116
-
117
- /**
118
- * @param {{ request: ReturnType<import("../pi/request.js").parseChatRequest>, graph: import("./graph.js").SessionGraph, logger: object }} deps
119
- */
120
- export async function planCompletion({ request, graph, logger }) {
121
- const { messages, tools, routing } = request;
122
-
123
- if (!routing) {
124
- return seedPlan({ request, strategy: STRATEGY.STATELESS_SEED, reason: "no_session_routing" });
125
- }
126
-
127
- const { session, parentNode, adoptedFrom } = await resolveParent({ graph, routing, logger });
128
-
129
- if (!parentNode) {
130
- let reason = "no_parent_entry";
131
- if (routing.parentEntryId) reason = graph.nodeCount(session) === 0 ? "session_unknown" : "parent_entry_unknown";
132
- return seedPlan({ request, strategy: STRATEGY.ROOT_SEED, reason, session, details: { parentEntryId: routing.parentEntryId } });
133
- }
134
-
135
- const verification = verifyContextExtendsNode(messages, parentNode);
136
- if (!verification.ok) {
137
- return seedPlan({
138
- request,
139
- strategy: STRATEGY.RESEED_DIVERGED,
140
- reason: verification.reason,
141
- details: { ...verification.details, parentEntryId: parentNode.piEntryId },
142
- session,
143
- parentNode,
144
- });
145
- }
146
-
147
- const deltaMessages = messages.slice(parentNode.context.messageCount + 1);
148
- if (deltaMessages.length === 0) {
149
- throw new HttpError(409, "Request contains no messages after the parent node's reply; nothing to send to DeepSeek.", {
150
- code: "no_new_messages",
151
- details: { parentEntryId: parentNode.piEntryId, messageCount: messages.length },
152
- });
153
- }
154
-
155
- const fingerprint = contextFingerprint(messages);
156
- const systemChanged = fingerprint.systemHash !== parentNode.context.systemHash;
157
- const systemUpdate = systemChanged ? renderSystemPrompt(hasLeadingSystem(messages) ? contentToText(messages[0].content) : "", tools) : null;
158
-
159
- return Object.freeze({
160
- strategy: STRATEGY.DELTA,
161
- reason: adoptedFrom ? `forked_from:${adoptedFrom}` : systemChanged ? "system_prompt_changed" : null,
162
- details: null,
163
- session,
164
- parentNode,
165
- prompt: renderDeltaPrompt(deltaMessages, tools, { systemUpdate }),
166
- deepseek: { sessionId: parentNode.deepseek.sessionId, parentMessageId: parentNode.deepseek.messageId },
167
- cachedTokens: graph.branchCachedTokens(session, parentNode),
168
- deltaMessageCount: deltaMessages.length,
169
- record: { piEntryId: routing.entryId, piParentEntryId: parentNode.piEntryId, context: fingerprint },
170
- });
171
- }
172
-
173
- /** Explicit re-seed after DeepSeek rejected the chat/parent a delta plan referenced. */
174
- export function reseedAfterUpstreamRejection(request, plan, details) {
175
- if (!plan.session) throw new BadRequestError("Cannot re-seed a stateless request", "reseed_without_session");
176
- return seedPlan({ request, strategy: STRATEGY.RESEED_UPSTREAM, reason: "upstream_context_rejected", details, session: plan.session, parentNode: plan.parentNode });
177
- }
@@ -1,100 +0,0 @@
1
- // Disk persistence for session graphs: one JSON file per pi session, atomic writes, TTL pruning.
2
-
3
- import crypto from "node:crypto";
4
- import path from "node:path";
5
- import { existsSync } from "node:fs";
6
- import { mkdir, readFile, readdir, rename, stat as statFs, unlink, writeFile } from "node:fs/promises";
7
-
8
- export const SESSION_SCHEMA_VERSION = 2;
9
-
10
- export function sanitizeSessionFilename(sessionId) {
11
- const safePrefix = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
12
- const hash = crypto.createHash("sha256").update(String(sessionId)).digest("hex").slice(0, 16);
13
- return `${safePrefix}_${hash}.json`;
14
- }
15
-
16
- export class SessionStore {
17
- constructor({ dir, ttlMs, logger }) {
18
- this.dir = dir;
19
- this.ttlMs = ttlMs;
20
- this.logger = logger;
21
- }
22
-
23
- filePath(sessionId) {
24
- return path.join(this.dir, sanitizeSessionFilename(sessionId));
25
- }
26
-
27
- async init() {
28
- if (!existsSync(this.dir)) await mkdir(this.dir, { recursive: true });
29
- }
30
-
31
- /**
32
- * Returns the stored session or null. Expired files are removed. Files written by the pre-v2 schema are
33
- * ignored (their node keys mixed pi and DeepSeek ids and cannot be trusted); they are reported once and
34
- * left to TTL pruning.
35
- */
36
- async read(sessionId, now = Date.now()) {
37
- const filePath = this.filePath(sessionId);
38
- if (!existsSync(filePath)) return null;
39
-
40
- let parsed;
41
- try {
42
- parsed = JSON.parse(await readFile(filePath, "utf8"));
43
- } catch (err) {
44
- this.logger.warn(`Session file ${path.basename(filePath)} is unreadable (${err.message}); treating session as unknown.`);
45
- return null;
46
- }
47
- if (!parsed || typeof parsed !== "object") return null;
48
-
49
- if (parsed.schemaVersion !== SESSION_SCHEMA_VERSION) {
50
- this.logger.warn(
51
- `Session file ${path.basename(filePath)} uses schema ${parsed.schemaVersion ?? 1} (expected ${SESSION_SCHEMA_VERSION}); ignoring it. The branch will be re-seeded.`
52
- );
53
- return null;
54
- }
55
- if (now - (parsed.lastAccessedAt || parsed.createdAt || 0) > this.ttlMs) {
56
- await unlink(filePath).catch(() => {});
57
- return null;
58
- }
59
- return parsed;
60
- }
61
-
62
- async write(sessionId, session) {
63
- await this.init();
64
- const filePath = this.filePath(sessionId);
65
- const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
66
- const json = JSON.stringify(session, null, 2);
67
- await writeFile(tmpPath, json, "utf8");
68
- try {
69
- await rename(tmpPath, filePath);
70
- } catch (err) {
71
- // Windows can refuse to replace a file that another process has open; write in place instead.
72
- this.logger.warn(`Atomic rename failed for ${path.basename(filePath)} (${err.message}); writing in place.`);
73
- await writeFile(filePath, json, "utf8");
74
- await unlink(tmpPath).catch(() => {});
75
- }
76
- }
77
-
78
- async remove(sessionId) {
79
- await unlink(this.filePath(sessionId)).catch(() => {});
80
- }
81
-
82
- async pruneOlderThan(now = Date.now()) {
83
- if (!existsSync(this.dir)) return 0;
84
- let removed = 0;
85
- for (const file of await readdir(this.dir)) {
86
- if (!file.endsWith(".json")) continue;
87
- const filePath = path.join(this.dir, file);
88
- try {
89
- const info = await statFs(filePath);
90
- if (now - info.mtimeMs > this.ttlMs) {
91
- await unlink(filePath);
92
- removed++;
93
- }
94
- } catch {
95
- // file vanished concurrently
96
- }
97
- }
98
- return removed;
99
- }
100
- }
package/src/utils/hash.js DELETED
@@ -1,27 +0,0 @@
1
- import crypto from "node:crypto";
2
-
3
- export function sha256Hex(input) {
4
- return crypto.createHash("sha256").update(input, "utf8").digest("hex");
5
- }
6
-
7
- /** Short (16 hex chars) digest used for fingerprints that are compared, never used as a key. */
8
- export function shortHash(input) {
9
- return sha256Hex(input).slice(0, 16);
10
- }
11
-
12
- /** Deterministic JSON: object keys sorted recursively so semantically equal values hash equally. */
13
- export function canonicalJson(value) {
14
- return JSON.stringify(sortKeys(value));
15
- }
16
-
17
- function sortKeys(value) {
18
- if (Array.isArray(value)) return value.map(sortKeys);
19
- if (value && typeof value === "object") {
20
- const out = {};
21
- for (const key of Object.keys(value).sort()) {
22
- out[key] = sortKeys(value[key]);
23
- }
24
- return out;
25
- }
26
- return value;
27
- }
File without changes
File without changes
File without changes