dsh-supermemory 0.1.0
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/LICENSE +25 -0
- package/NOTICE +47 -0
- package/README.md +226 -0
- package/cordis.patch.yml +3 -0
- package/lib/index.d.mts +200 -0
- package/lib/index.mjs +1282 -0
- package/lib/mcp-proxy.d.mts +1 -0
- package/lib/mcp-proxy.mjs +96 -0
- package/lib/settings-BysiYjKM.mjs +296 -0
- package/lib/statusline.d.mts +35 -0
- package/lib/statusline.mjs +203 -0
- package/package.json +127 -0
- package/skills/supermemory-context-gatherer/SKILL.md +29 -0
- package/templates/auth-error.html +18 -0
- package/templates/auth-success.html +39 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
import { a as getIncludeTools, c as loadSettings, d as getGitRoot, f as AUTH_BASE_URL, h as startAuthFlow, i as getBaseUrl, l as shouldIncludeTool, m as loadCredentials, n as debugLog, o as getRecallConfig, p as CREDENTIALS_FILE, r as getApiKey, s as getSignalConfig, t as SETTINGS_FILE, u as loadProjectConfig } from "./settings-BysiYjKM.mjs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import * as McpClient from "@deepseek-ai/dsh-mcp-client";
|
|
4
|
+
import z from "@deepseek-ai/schemastery";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
11
|
+
//#region src/config.ts
|
|
12
|
+
const PluginConfig = z.object({
|
|
13
|
+
injectProfile: z.boolean().default(true),
|
|
14
|
+
recall: z.boolean().default(true),
|
|
15
|
+
capture: z.boolean().default(true),
|
|
16
|
+
autoApprove: z.boolean().default(true),
|
|
17
|
+
browserLogin: z.boolean().default(true),
|
|
18
|
+
mcp: z.boolean().default(true),
|
|
19
|
+
mcpServerName: z.string().default("supermemory"),
|
|
20
|
+
command: z.boolean().default(true),
|
|
21
|
+
contextGatherer: z.boolean().default(true),
|
|
22
|
+
includeSubagents: z.boolean().default(false)
|
|
23
|
+
});
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/lib/statusline-state.ts
|
|
26
|
+
const SCHEMA_VERSION = 1;
|
|
27
|
+
const STATE_DIR_NAME = "statusline-state";
|
|
28
|
+
const EVENT_NAMES = /* @__PURE__ */ new Set([
|
|
29
|
+
"context",
|
|
30
|
+
"capture",
|
|
31
|
+
"search"
|
|
32
|
+
]);
|
|
33
|
+
const SESSION_RETENTION_MS = 6048e5;
|
|
34
|
+
function resolveStatuslineDataDir(explicitDir) {
|
|
35
|
+
return explicitDir || path.join(os.homedir(), ".supermemory-claude", "statusline");
|
|
36
|
+
}
|
|
37
|
+
function hashValue(value) {
|
|
38
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
function normalizeCount(value) {
|
|
41
|
+
const count = Number(value);
|
|
42
|
+
if (!Number.isFinite(count)) return 0;
|
|
43
|
+
return Math.max(0, Math.floor(count));
|
|
44
|
+
}
|
|
45
|
+
function getStateRoot(dataDir) {
|
|
46
|
+
return path.join(resolveStatuslineDataDir(dataDir), STATE_DIR_NAME);
|
|
47
|
+
}
|
|
48
|
+
function getSessionDir(sessionId, dataDir) {
|
|
49
|
+
if (typeof sessionId !== "string" || !sessionId.trim()) return null;
|
|
50
|
+
return path.join(getStateRoot(dataDir), hashValue(sessionId.trim()));
|
|
51
|
+
}
|
|
52
|
+
function ensurePrivateDir(dir) {
|
|
53
|
+
fs.mkdirSync(dir, {
|
|
54
|
+
recursive: true,
|
|
55
|
+
mode: 448
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
fs.chmodSync(dir, 448);
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
function atomicWriteJson(file, value) {
|
|
62
|
+
const dir = path.dirname(file);
|
|
63
|
+
ensurePrivateDir(dir);
|
|
64
|
+
const temporary = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(temporary, JSON.stringify(value), {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
flag: "wx",
|
|
69
|
+
mode: 384
|
|
70
|
+
});
|
|
71
|
+
fs.renameSync(temporary, file);
|
|
72
|
+
try {
|
|
73
|
+
fs.chmodSync(file, 384);
|
|
74
|
+
} catch {}
|
|
75
|
+
} finally {
|
|
76
|
+
try {
|
|
77
|
+
fs.unlinkSync(temporary);
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function sanitizeEvent(event, data) {
|
|
82
|
+
if (event === "context") return {
|
|
83
|
+
status: [
|
|
84
|
+
"loading",
|
|
85
|
+
"ready",
|
|
86
|
+
"error"
|
|
87
|
+
].includes(data.status) ? data.status : "ready",
|
|
88
|
+
memoryItemsLoaded: normalizeCount(data.memoryItemsLoaded)
|
|
89
|
+
};
|
|
90
|
+
if (event === "capture") return {
|
|
91
|
+
status: [
|
|
92
|
+
"saving",
|
|
93
|
+
"saved",
|
|
94
|
+
"error"
|
|
95
|
+
].includes(data.status) ? data.status : "error",
|
|
96
|
+
count: normalizeCount(data.count)
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
results: normalizeCount(data.results),
|
|
100
|
+
count: normalizeCount(data.count),
|
|
101
|
+
memories: normalizeCount(data.memories)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function writeState(sessionId, event, data = {}, options = {}) {
|
|
105
|
+
if (!EVENT_NAMES.has(event)) return false;
|
|
106
|
+
const sessionDir = getSessionDir(sessionId, options.dataDir);
|
|
107
|
+
if (!sessionDir) return false;
|
|
108
|
+
try {
|
|
109
|
+
const record = {
|
|
110
|
+
version: SCHEMA_VERSION,
|
|
111
|
+
event,
|
|
112
|
+
updatedAt: options.now ?? Date.now(),
|
|
113
|
+
...sanitizeEvent(event, data)
|
|
114
|
+
};
|
|
115
|
+
atomicWriteJson(path.join(sessionDir, `${event}.json`), record);
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function readEvent(sessionDir, event) {
|
|
122
|
+
try {
|
|
123
|
+
const record = JSON.parse(fs.readFileSync(path.join(sessionDir, `${event}.json`), "utf8"));
|
|
124
|
+
if (record?.version !== SCHEMA_VERSION || record?.event !== event || !Number.isFinite(record?.updatedAt)) return null;
|
|
125
|
+
return record;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function readState(sessionId, options = {}) {
|
|
131
|
+
const sessionDir = getSessionDir(sessionId, options.dataDir);
|
|
132
|
+
if (!sessionDir) return {};
|
|
133
|
+
return {
|
|
134
|
+
context: readEvent(sessionDir, "context"),
|
|
135
|
+
capture: readEvent(sessionDir, "capture"),
|
|
136
|
+
search: readEvent(sessionDir, "search")
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function pruneState(options = {}) {
|
|
140
|
+
const root = getStateRoot(options.dataDir);
|
|
141
|
+
const cutoff = (options.now ?? Date.now()) - SESSION_RETENTION_MS;
|
|
142
|
+
try {
|
|
143
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
144
|
+
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name)) continue;
|
|
145
|
+
const sessionDir = path.join(root, entry.name);
|
|
146
|
+
let newest = 0;
|
|
147
|
+
try {
|
|
148
|
+
newest = fs.statSync(sessionDir).mtimeMs;
|
|
149
|
+
for (const file of fs.readdirSync(sessionDir)) newest = Math.max(newest, fs.statSync(path.join(sessionDir, file)).mtimeMs);
|
|
150
|
+
} catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (newest < cutoff) fs.rmSync(sessionDir, {
|
|
154
|
+
recursive: true,
|
|
155
|
+
force: true
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/lib/colors.ts
|
|
162
|
+
const enabled = !process.env.NO_COLOR;
|
|
163
|
+
const wrap = (code) => (s) => enabled ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
164
|
+
const blue = wrap("38;2;124;120;250");
|
|
165
|
+
const bold = wrap("1");
|
|
166
|
+
const gray = wrap("38;5;245");
|
|
167
|
+
const red = wrap("31");
|
|
168
|
+
const MARK = blue(bold("◪"));
|
|
169
|
+
const BRAND = `${MARK} ${bold("supermemory")}`;
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region src/runtime.ts
|
|
172
|
+
/** The `{kind:'plugin'}` source stamped on every context this plugin injects. */
|
|
173
|
+
const PLUGIN_SOURCE = {
|
|
174
|
+
kind: "plugin",
|
|
175
|
+
plugin: "supermemory"
|
|
176
|
+
};
|
|
177
|
+
function createRuntime(ctx) {
|
|
178
|
+
const logger = ctx.logger("supermemory");
|
|
179
|
+
return {
|
|
180
|
+
ctx,
|
|
181
|
+
bootstraps: /* @__PURE__ */ new Map(),
|
|
182
|
+
delivered: /* @__PURE__ */ new Set(),
|
|
183
|
+
notify(text) {
|
|
184
|
+
logger.info(`${BRAND} ${gray("·")} ${text}`);
|
|
185
|
+
},
|
|
186
|
+
warn(text) {
|
|
187
|
+
logger.warn(`${BRAND} ${gray("·")} ${text}`);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** The session workspace an agent runs in, matching every other DSH plugin. */
|
|
192
|
+
function cwdOf(agent) {
|
|
193
|
+
return agent?.session.header.cwd ?? process.cwd();
|
|
194
|
+
}
|
|
195
|
+
function sessionIdOf(agent) {
|
|
196
|
+
return String(agent?.session.header.id ?? "");
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Claude Code fires SessionStart, UserPromptSubmit, and Stop for the main
|
|
200
|
+
* session only — a Task subagent gets SubagentStart/SubagentStop, which this
|
|
201
|
+
* plugin does not hook. DSH dispatches the same extension points to every
|
|
202
|
+
* agent, so delegated sessions are filtered out to keep the behavior identical.
|
|
203
|
+
*/
|
|
204
|
+
function isSubagent(agent) {
|
|
205
|
+
return agent?.session.header.origin === "subagent";
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/approve.ts
|
|
209
|
+
const TOOL_NAME_RE = /^mcp__(?:plugin_supermemory_|claude_ai_)?supermemory__(.+)$/;
|
|
210
|
+
const READ_ONLY_TOOLS = /* @__PURE__ */ new Set([
|
|
211
|
+
"search_memory",
|
|
212
|
+
"listSpaces",
|
|
213
|
+
"listMemories",
|
|
214
|
+
"listDocuments",
|
|
215
|
+
"getDocument",
|
|
216
|
+
"whoAmI",
|
|
217
|
+
"memory-graph",
|
|
218
|
+
"fetch-graph-data"
|
|
219
|
+
]);
|
|
220
|
+
/** The read-only tool name behind a supermemory MCP call, or null. */
|
|
221
|
+
function readOnlyToolOf(toolName, serverName) {
|
|
222
|
+
const escaped = serverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
223
|
+
const tool = new RegExp(`^mcp__${escaped}__(.+)$`).exec(toolName)?.[1] ?? TOOL_NAME_RE.exec(toolName)?.[1];
|
|
224
|
+
return tool && READ_ONLY_TOOLS.has(tool) ? tool : null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Registered with `prepend: true` so it sits outermost in the waterfall and
|
|
228
|
+
* returns without delegating: no later listener — including a composed Claude
|
|
229
|
+
* Code hooks bridge that would answer `ask` — can turn a read-only recall into
|
|
230
|
+
* an approval prompt. Monotonic guards still apply, by design.
|
|
231
|
+
*/
|
|
232
|
+
function registerApprove(ctx, rt, config) {
|
|
233
|
+
const serverName = config.mcpServerName ?? "supermemory";
|
|
234
|
+
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
235
|
+
const settings = loadSettings();
|
|
236
|
+
try {
|
|
237
|
+
const tool = readOnlyToolOf(exec.name, serverName);
|
|
238
|
+
if (!tool) return next();
|
|
239
|
+
debugLog(settings, "Auto-approving supermemory recall", { tool });
|
|
240
|
+
const sessionId = sessionIdOf(exec.agent);
|
|
241
|
+
const query = typeof exec.arguments?.query === "string" ? exec.arguments.query : null;
|
|
242
|
+
if (tool === "search_memory" && sessionId) {
|
|
243
|
+
const prev = readState(sessionId).search;
|
|
244
|
+
writeState(sessionId, "search", {
|
|
245
|
+
results: 0,
|
|
246
|
+
count: (prev?.count ?? 0) + 1,
|
|
247
|
+
memories: prev?.memories ?? 0
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
rt.notify(query ? `recalling: ${query}` : "recalling memories");
|
|
251
|
+
return { kind: "allow" };
|
|
252
|
+
} catch (err) {
|
|
253
|
+
debugLog(settings, "Recall approve error", { error: err.message });
|
|
254
|
+
return next();
|
|
255
|
+
}
|
|
256
|
+
}, { prepend: true });
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/lib/api.ts
|
|
260
|
+
const AGENT_ENTITY_CONTEXT = `Shared coding-agent memory for one software repository.
|
|
261
|
+
|
|
262
|
+
RULES:
|
|
263
|
+
- Try to remember things that a human would remember — a teammate recalls decisions and lessons, not what state the working tree was in
|
|
264
|
+
- Preserve durable context that helps a coding agent continue the work
|
|
265
|
+
- Condense assistant responses into decisions, outcomes, and reusable knowledge
|
|
266
|
+
- Keep user preferences and project facts concise and independently understandable
|
|
267
|
+
|
|
268
|
+
EXTRACT:
|
|
269
|
+
- User preferences, accepted decisions, durable workflows, actions, and learnings
|
|
270
|
+
- Architecture: "uses monorepo with turborepo", "API in /apps/api"
|
|
271
|
+
- Conventions: "components in PascalCase", "hooks prefixed with use"
|
|
272
|
+
- Patterns: "all API routes use withAuth wrapper", "errors thrown as ApiError"
|
|
273
|
+
- Setup: "requires .env with DATABASE_URL", "run pnpm db:migrate first"
|
|
274
|
+
- Decisions: "chose Drizzle over Prisma for performance", "using RSC for data fetching"
|
|
275
|
+
|
|
276
|
+
SKIP:
|
|
277
|
+
- Transient repo state git already tracks: uncommitted file lists, current branch position, in-flight commit/push status
|
|
278
|
+
- Generic assistant suggestions the user did not accept
|
|
279
|
+
- Transient command output and low-value implementation chatter
|
|
280
|
+
- Granular details that do not help future work`;
|
|
281
|
+
const REQUEST_TIMEOUT_MS = 3e3;
|
|
282
|
+
async function post(baseUrl, apiKey, path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
283
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: {
|
|
286
|
+
Authorization: `Bearer ${apiKey}`,
|
|
287
|
+
"Content-Type": "application/json",
|
|
288
|
+
"x-sm-source": "claude-code"
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify(body),
|
|
291
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
292
|
+
});
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
const text = await response.text().catch(() => "");
|
|
295
|
+
throw Object.assign(/* @__PURE__ */ new Error(`Supermemory API ${response.status}: ${text.slice(0, 200)}`), { status: response.status });
|
|
296
|
+
}
|
|
297
|
+
return await response.json();
|
|
298
|
+
}
|
|
299
|
+
function getProfile(baseUrl, apiKey, containerTag, query, options = {}) {
|
|
300
|
+
return post(baseUrl, apiKey, "/v4/profile", {
|
|
301
|
+
containerTag,
|
|
302
|
+
q: query
|
|
303
|
+
}, options.timeoutMs);
|
|
304
|
+
}
|
|
305
|
+
function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = {}) {
|
|
306
|
+
const body = {
|
|
307
|
+
content,
|
|
308
|
+
containerTag,
|
|
309
|
+
metadata: {
|
|
310
|
+
sm_source: "claude-code",
|
|
311
|
+
...metadata
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
if (options.customId) body.customId = options.customId;
|
|
315
|
+
if (options.entityContext) body.entityContext = options.entityContext;
|
|
316
|
+
return post(baseUrl, apiKey, "/v3/documents", body, options.timeoutMs);
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/lib/container-tag.ts
|
|
320
|
+
function sha256(input) {
|
|
321
|
+
return crypto.createHash("sha256").update(input).digest("hex").slice(0, 16);
|
|
322
|
+
}
|
|
323
|
+
const repoInfoCache = /* @__PURE__ */ new Map();
|
|
324
|
+
function normalizeGitRemote(remoteUrl) {
|
|
325
|
+
const raw = remoteUrl.trim();
|
|
326
|
+
if (!raw) return null;
|
|
327
|
+
let normalized;
|
|
328
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(raw)) try {
|
|
329
|
+
const parsed = new URL(raw);
|
|
330
|
+
if (parsed.protocol === "file:") normalized = `file:${decodeURIComponent(parsed.pathname)}`;
|
|
331
|
+
else normalized = `${parsed.hostname.toLowerCase()}${parsed.port ? `:${parsed.port}` : ""}/${parsed.pathname.replace(/^\/+/, "")}`;
|
|
332
|
+
} catch {
|
|
333
|
+
normalized = raw;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
const scpStyle = raw.match(/^(?:[^@/]+@)?([^:]+):(.+)$/);
|
|
337
|
+
normalized = scpStyle ? `${scpStyle[1].toLowerCase()}/${scpStyle[2]}` : `file:${path.resolve(raw)}`;
|
|
338
|
+
}
|
|
339
|
+
return normalized.replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/{2,}/g, "/").toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
function getGitRepoInfo(cwd) {
|
|
342
|
+
const cached = repoInfoCache.get(cwd);
|
|
343
|
+
if (cached) return cached;
|
|
344
|
+
try {
|
|
345
|
+
const remoteUrl = execSync("git remote get-url origin", {
|
|
346
|
+
cwd,
|
|
347
|
+
encoding: "utf-8",
|
|
348
|
+
stdio: [
|
|
349
|
+
"pipe",
|
|
350
|
+
"pipe",
|
|
351
|
+
"pipe"
|
|
352
|
+
]
|
|
353
|
+
}).trim();
|
|
354
|
+
const normalizedRemote = normalizeGitRemote(remoteUrl);
|
|
355
|
+
const displayRemote = remoteUrl.replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
356
|
+
const separator = Math.max(displayRemote.lastIndexOf("/"), displayRemote.lastIndexOf(":"));
|
|
357
|
+
const result = {
|
|
358
|
+
name: displayRemote.slice(separator + 1) || null,
|
|
359
|
+
normalizedRemote
|
|
360
|
+
};
|
|
361
|
+
repoInfoCache.set(cwd, result);
|
|
362
|
+
return result;
|
|
363
|
+
} catch {
|
|
364
|
+
const result = {
|
|
365
|
+
name: null,
|
|
366
|
+
normalizedRemote: null
|
|
367
|
+
};
|
|
368
|
+
repoInfoCache.set(cwd, result);
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function getGitRepoName(cwd) {
|
|
373
|
+
return getGitRepoInfo(cwd).name;
|
|
374
|
+
}
|
|
375
|
+
function getProjectBasePath(cwd) {
|
|
376
|
+
return getGitRoot(cwd) || path.resolve(cwd);
|
|
377
|
+
}
|
|
378
|
+
function sanitizeRepoName(name) {
|
|
379
|
+
return (name.toLowerCase().replace(/[^a-z0-9]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "") || "unknown").slice(0, 95).replace(/_+$/g, "") || "unknown";
|
|
380
|
+
}
|
|
381
|
+
function getProjectIdentity(cwd) {
|
|
382
|
+
const basePath = getProjectBasePath(cwd);
|
|
383
|
+
const { normalizedRemote } = getGitRepoInfo(basePath);
|
|
384
|
+
const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true";
|
|
385
|
+
let localIdentity = basePath;
|
|
386
|
+
try {
|
|
387
|
+
localIdentity = fs.realpathSync.native(basePath);
|
|
388
|
+
} catch {}
|
|
389
|
+
return sha256(!isolateWorktrees && normalizedRemote ? normalizedRemote : `path:${localIdentity}`);
|
|
390
|
+
}
|
|
391
|
+
function getGeneratedContainerTag(cwd) {
|
|
392
|
+
const basePath = getProjectBasePath(cwd);
|
|
393
|
+
return `repo_${sanitizeRepoName(getGitRepoName(basePath) || path.basename(basePath) || "unknown").slice(0, 72).replace(/_+$/g, "") || "unknown"}__${getProjectIdentity(cwd)}`;
|
|
394
|
+
}
|
|
395
|
+
function getContainerTag(cwd) {
|
|
396
|
+
return loadProjectConfig(cwd)?.repoContainerTag || process.env.SUPERMEMORY_REPO_TAG || getGeneratedContainerTag(cwd);
|
|
397
|
+
}
|
|
398
|
+
function getProjectName(cwd) {
|
|
399
|
+
const basePath = getProjectBasePath(cwd);
|
|
400
|
+
return getGitRepoName(basePath) || path.basename(basePath) || "unknown";
|
|
401
|
+
}
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/lib/error-helpers.ts
|
|
404
|
+
/** Map an API error (or any Error) to a concise, actionable message. */
|
|
405
|
+
function getUserFriendlyError(err) {
|
|
406
|
+
const error = err ?? {};
|
|
407
|
+
const status = error.status;
|
|
408
|
+
if (error.name === "TimeoutError" || error.name === "AbortError" || error.message === "fetch failed") return "Supermemory unreachable (network) — continuing without memory.";
|
|
409
|
+
if (status === 400) return "Bad request — your API key or request format may be invalid. Check your key at https://console.supermemory.ai";
|
|
410
|
+
if (status === 401) return "Authentication failed — your API key may be expired or revoked. Re-authenticate with the supermemory login command or check https://console.supermemory.ai";
|
|
411
|
+
if (status === 403) return "Permission denied — this feature may require a different Supermemory plan. Check https://supermemory.ai/pricing";
|
|
412
|
+
if (status === 429) return "Rate limited — too many requests. Will retry next session.";
|
|
413
|
+
if (typeof status === "number" && status >= 500) return "Supermemory service is temporarily unavailable. Will retry next session.";
|
|
414
|
+
return error.message || "Unknown error";
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/lib/last-session.ts
|
|
418
|
+
const SETTINGS_DIR = path.join(os.homedir(), ".supermemory-claude");
|
|
419
|
+
const LAST_SESSION_FILE = path.join(SETTINGS_DIR, "last-session.json");
|
|
420
|
+
const OLD_PLAIN_ID_FILE = path.join(SETTINGS_DIR, "last-session-document-id");
|
|
421
|
+
function ensureDir() {
|
|
422
|
+
if (!fs.existsSync(SETTINGS_DIR)) fs.mkdirSync(SETTINGS_DIR, { recursive: true });
|
|
423
|
+
}
|
|
424
|
+
/** Save the current session's Supermemory document info for deep links. */
|
|
425
|
+
function saveLastSession({ id, containerTag }) {
|
|
426
|
+
if (!id) return;
|
|
427
|
+
ensureDir();
|
|
428
|
+
const data = {
|
|
429
|
+
id,
|
|
430
|
+
containerTag: containerTag || null,
|
|
431
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
432
|
+
};
|
|
433
|
+
fs.writeFileSync(LAST_SESSION_FILE, JSON.stringify(data, null, 2));
|
|
434
|
+
try {
|
|
435
|
+
if (fs.existsSync(OLD_PLAIN_ID_FILE)) fs.unlinkSync(OLD_PLAIN_ID_FILE);
|
|
436
|
+
} catch {}
|
|
437
|
+
}
|
|
438
|
+
//#endregion
|
|
439
|
+
//#region src/transcript.ts
|
|
440
|
+
/**
|
|
441
|
+
* Turn-delta extraction, ported from the Claude Code plugin's transcript
|
|
442
|
+
* reader. Claude Code parses a JSONL transcript file; DSH exposes no artifact
|
|
443
|
+
* path, so the same delta is read from the durable session log instead. The
|
|
444
|
+
* emitted text — the `<|start|>role<|message|>…<|end|>` lines inside a
|
|
445
|
+
* `<|turn_start|>`/`<|turn_end|>` envelope — is byte-identical to what the
|
|
446
|
+
* Claude Code plugin sends, so both harnesses write memories in one shape.
|
|
447
|
+
*
|
|
448
|
+
* The cursor is a `SessionSeq` rather than a message uuid, stored in the same
|
|
449
|
+
* tracker directory the Claude Code plugin uses.
|
|
450
|
+
*/
|
|
451
|
+
const MAX_TOOL_RESULT_LENGTH = 500;
|
|
452
|
+
const TRACKER_DIR = path.join(os.homedir(), ".supermemory-claude", "trackers");
|
|
453
|
+
let toolUseMap = /* @__PURE__ */ new Map();
|
|
454
|
+
let currentIncludeList = [];
|
|
455
|
+
function ensureTrackerDir() {
|
|
456
|
+
if (!fs.existsSync(TRACKER_DIR)) fs.mkdirSync(TRACKER_DIR, { recursive: true });
|
|
457
|
+
}
|
|
458
|
+
/** The last captured sequence number for a session, or null before the first capture. */
|
|
459
|
+
function getLastCapturedSeq(sessionId) {
|
|
460
|
+
ensureTrackerDir();
|
|
461
|
+
const trackerFile = path.join(TRACKER_DIR, `${sessionId}.txt`);
|
|
462
|
+
if (fs.existsSync(trackerFile)) {
|
|
463
|
+
const raw = fs.readFileSync(trackerFile, "utf-8").trim();
|
|
464
|
+
const seq = Number.parseInt(raw, 10);
|
|
465
|
+
return Number.isFinite(seq) ? seq : null;
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
function setLastCapturedSeq(sessionId, seq) {
|
|
470
|
+
ensureTrackerDir();
|
|
471
|
+
const trackerFile = path.join(TRACKER_DIR, `${sessionId}.txt`);
|
|
472
|
+
fs.writeFileSync(trackerFile, String(seq));
|
|
473
|
+
}
|
|
474
|
+
function blocksOf(value) {
|
|
475
|
+
const content = value?.content;
|
|
476
|
+
return Array.isArray(content) ? content : [];
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Project session-log events onto the user/assistant entry list the Claude
|
|
480
|
+
* Code formatter expects. A `tool/result` event becomes a user entry carrying
|
|
481
|
+
* one tool-result block, exactly where Claude Code's transcript puts it.
|
|
482
|
+
*/
|
|
483
|
+
function entriesFromEvents(events) {
|
|
484
|
+
const entries = [];
|
|
485
|
+
for (const event of events) {
|
|
486
|
+
const timestamp = new Date(event.time ?? Date.now()).toISOString();
|
|
487
|
+
if (event.type === "user/message") entries.push({
|
|
488
|
+
type: "user",
|
|
489
|
+
seq: event.seq,
|
|
490
|
+
timestamp,
|
|
491
|
+
content: blocksOf(event.data)
|
|
492
|
+
});
|
|
493
|
+
else if (event.type === "assistant/message") {
|
|
494
|
+
const message = event.data?.message;
|
|
495
|
+
entries.push({
|
|
496
|
+
type: "assistant",
|
|
497
|
+
seq: event.seq,
|
|
498
|
+
timestamp,
|
|
499
|
+
content: blocksOf(message)
|
|
500
|
+
});
|
|
501
|
+
} else if (event.type === "tool/result") {
|
|
502
|
+
const message = event.data?.message;
|
|
503
|
+
entries.push({
|
|
504
|
+
type: "user",
|
|
505
|
+
seq: event.seq,
|
|
506
|
+
timestamp,
|
|
507
|
+
content: blocksOf(message)
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return entries;
|
|
512
|
+
}
|
|
513
|
+
function textOfBlocks(blocks) {
|
|
514
|
+
if (!Array.isArray(blocks)) return "";
|
|
515
|
+
return blocks.filter((block) => block.type === "text").map((block) => block.text ?? "").join("");
|
|
516
|
+
}
|
|
517
|
+
function formatUserEntry(entry) {
|
|
518
|
+
const parts = [];
|
|
519
|
+
for (const block of entry.content) if (block.type === "text") {
|
|
520
|
+
const cleaned = cleanContent(block.text ?? "");
|
|
521
|
+
if (cleaned) parts.push(`<|start|>user<|message|>${cleaned}<|end|>`);
|
|
522
|
+
} else if (block.type === "tool-result") {
|
|
523
|
+
const result = block;
|
|
524
|
+
const toolName = toolUseMap.get(result.toolCallId ?? "") || "Unknown";
|
|
525
|
+
if (!shouldIncludeTool(toolName, currentIncludeList)) continue;
|
|
526
|
+
const resultContent = truncate(cleanContent(textOfBlocks(result.content)), MAX_TOOL_RESULT_LENGTH);
|
|
527
|
+
const status = result.isError ? "error" : "success";
|
|
528
|
+
if (resultContent) parts.push(`<|start|>assistant:tool_result<|message|>${toolName}(${status}): ${resultContent}<|end|>`);
|
|
529
|
+
}
|
|
530
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
531
|
+
}
|
|
532
|
+
function formatAssistantEntry(entry) {
|
|
533
|
+
const parts = [];
|
|
534
|
+
for (const block of entry.content) {
|
|
535
|
+
if (block.type === "reasoning") continue;
|
|
536
|
+
if (block.type === "text") {
|
|
537
|
+
const cleaned = cleanContent(block.text ?? "");
|
|
538
|
+
if (cleaned) parts.push(`<|start|>assistant<|message|>${cleaned}<|end|>`);
|
|
539
|
+
} else if (block.type === "tool-call") {
|
|
540
|
+
const call = block;
|
|
541
|
+
const toolName = call.name || "Unknown";
|
|
542
|
+
const toolId = call.id || "";
|
|
543
|
+
if (toolId) toolUseMap.set(toolId, toolName);
|
|
544
|
+
if (!shouldIncludeTool(toolName, currentIncludeList)) continue;
|
|
545
|
+
parts.push(`<|start|>assistant:tool<|message|>${toolName}: ${formatToolInputCompact(call.arguments)}<|end|>`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
549
|
+
}
|
|
550
|
+
function formatEntry(entry) {
|
|
551
|
+
return (entry.type === "user" ? formatUserEntry(entry) : formatAssistantEntry(entry)) ?? "";
|
|
552
|
+
}
|
|
553
|
+
function formatToolInputCompact(rawArguments) {
|
|
554
|
+
let input = {};
|
|
555
|
+
try {
|
|
556
|
+
const parsed = JSON.parse(rawArguments ?? "{}");
|
|
557
|
+
if (parsed && typeof parsed === "object") input = parsed;
|
|
558
|
+
} catch {
|
|
559
|
+
return truncate(rawArguments ?? "", 100) ?? "";
|
|
560
|
+
}
|
|
561
|
+
const parts = [];
|
|
562
|
+
for (const [key, value] of Object.entries(input)) {
|
|
563
|
+
let valueStr = typeof value === "string" ? value : JSON.stringify(value);
|
|
564
|
+
valueStr = truncate(valueStr, 100) ?? "";
|
|
565
|
+
parts.push(`${key}="${valueStr}"`);
|
|
566
|
+
}
|
|
567
|
+
return parts.join(" ");
|
|
568
|
+
}
|
|
569
|
+
function cleanContent(text) {
|
|
570
|
+
if (!text || typeof text !== "string") return "";
|
|
571
|
+
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").replace(/<supermemory-context>[\s\S]*?<\/supermemory-context>/g, "").replace(/<supermemory-recall>[\s\S]*?<\/supermemory-recall>/g, "").replace(/<supermemory-status>[\s\S]*?<\/supermemory-status>/g, "").trim();
|
|
572
|
+
}
|
|
573
|
+
function truncate(text, maxLength) {
|
|
574
|
+
if (!text || text.length <= maxLength) return text;
|
|
575
|
+
return `${text.slice(0, maxLength)}...`;
|
|
576
|
+
}
|
|
577
|
+
function getTextFromEntry(entry) {
|
|
578
|
+
return cleanContent(textOfBlocks(entry.content));
|
|
579
|
+
}
|
|
580
|
+
function hasTextContent(entry) {
|
|
581
|
+
return getTextFromEntry(entry).length > 0;
|
|
582
|
+
}
|
|
583
|
+
function groupEntriesIntoTurns(entries) {
|
|
584
|
+
const turns = [];
|
|
585
|
+
let currentTurn = {
|
|
586
|
+
userEntries: [],
|
|
587
|
+
assistantEntries: [],
|
|
588
|
+
allEntries: []
|
|
589
|
+
};
|
|
590
|
+
for (const entry of entries) if (entry.type === "user") {
|
|
591
|
+
if (currentTurn.assistantEntries.length > 0) {
|
|
592
|
+
turns.push(currentTurn);
|
|
593
|
+
currentTurn = {
|
|
594
|
+
userEntries: [],
|
|
595
|
+
assistantEntries: [],
|
|
596
|
+
allEntries: []
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
currentTurn.userEntries.push(entry);
|
|
600
|
+
currentTurn.allEntries.push(entry);
|
|
601
|
+
} else {
|
|
602
|
+
currentTurn.assistantEntries.push(entry);
|
|
603
|
+
currentTurn.allEntries.push(entry);
|
|
604
|
+
}
|
|
605
|
+
if (currentTurn.allEntries.length > 0) turns.push(currentTurn);
|
|
606
|
+
return turns;
|
|
607
|
+
}
|
|
608
|
+
function groupEntriesIntoSignalTurns(entries) {
|
|
609
|
+
const turns = [];
|
|
610
|
+
let currentTurn = { userEntries: [] };
|
|
611
|
+
let lastAssistantEntry = null;
|
|
612
|
+
const pushTurn = () => {
|
|
613
|
+
if (currentTurn.userEntries.length === 0 && !lastAssistantEntry) return;
|
|
614
|
+
const assistantEntries = lastAssistantEntry ? [lastAssistantEntry] : [];
|
|
615
|
+
const allEntries = [...currentTurn.userEntries, ...assistantEntries];
|
|
616
|
+
turns.push({
|
|
617
|
+
userEntries: currentTurn.userEntries,
|
|
618
|
+
assistantEntries,
|
|
619
|
+
allEntries
|
|
620
|
+
});
|
|
621
|
+
currentTurn = { userEntries: [] };
|
|
622
|
+
lastAssistantEntry = null;
|
|
623
|
+
};
|
|
624
|
+
for (const entry of entries) {
|
|
625
|
+
if (!hasTextContent(entry)) continue;
|
|
626
|
+
if (entry.type === "user") {
|
|
627
|
+
if (lastAssistantEntry) pushTurn();
|
|
628
|
+
currentTurn.userEntries.push(entry);
|
|
629
|
+
} else lastAssistantEntry = entry;
|
|
630
|
+
}
|
|
631
|
+
pushTurn();
|
|
632
|
+
return turns;
|
|
633
|
+
}
|
|
634
|
+
function getTurnUserText(turn) {
|
|
635
|
+
const texts = [];
|
|
636
|
+
for (const entry of turn.userEntries) {
|
|
637
|
+
const text = getTextFromEntry(entry);
|
|
638
|
+
if (text) texts.push(text);
|
|
639
|
+
}
|
|
640
|
+
return texts.join(" ").toLowerCase();
|
|
641
|
+
}
|
|
642
|
+
function findSignalTurnIndices(turns, keywords) {
|
|
643
|
+
const signalIndices = [];
|
|
644
|
+
for (let i = 0; i < turns.length; i++) {
|
|
645
|
+
const userText = getTurnUserText(turns[i]);
|
|
646
|
+
for (const keyword of keywords) if (userText.includes(keyword)) {
|
|
647
|
+
signalIndices.push(i);
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return signalIndices;
|
|
652
|
+
}
|
|
653
|
+
function getTurnsAroundSignals(turns, signalIndices, turnCount) {
|
|
654
|
+
if (signalIndices.length === 0) return [];
|
|
655
|
+
const includeSet = /* @__PURE__ */ new Set();
|
|
656
|
+
for (const signalIdx of signalIndices) {
|
|
657
|
+
const startIdx = Math.max(0, signalIdx - (turnCount - 1));
|
|
658
|
+
for (let i = startIdx; i <= signalIdx; i++) includeSet.add(i);
|
|
659
|
+
}
|
|
660
|
+
return Array.from(includeSet).sort((a, b) => a - b).map((idx) => turns[idx]);
|
|
661
|
+
}
|
|
662
|
+
function formatEntryTextOnly(entry) {
|
|
663
|
+
const role = entry.type;
|
|
664
|
+
const parts = [];
|
|
665
|
+
for (const block of entry.content) {
|
|
666
|
+
if (block.type !== "text") continue;
|
|
667
|
+
const cleaned = cleanContent(block.text ?? "");
|
|
668
|
+
if (cleaned) parts.push(`<|start|>${role}<|message|>${cleaned}<|end|>`);
|
|
669
|
+
}
|
|
670
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
671
|
+
}
|
|
672
|
+
function envelope(entries, format) {
|
|
673
|
+
const formattedParts = [`<|turn_start|>${entries[0]?.timestamp || (/* @__PURE__ */ new Date()).toISOString()}`];
|
|
674
|
+
for (const entry of entries) {
|
|
675
|
+
const formatted = format(entry);
|
|
676
|
+
if (formatted) formattedParts.push(formatted);
|
|
677
|
+
}
|
|
678
|
+
formattedParts.push("<|turn_end|>");
|
|
679
|
+
return formattedParts.join("\n\n");
|
|
680
|
+
}
|
|
681
|
+
function newEntries(session, sessionId) {
|
|
682
|
+
const lastSeq = getLastCapturedSeq(sessionId);
|
|
683
|
+
return entriesFromEvents(session.snapshotEvents(lastSeq === null ? void 0 : lastSeq + 1));
|
|
684
|
+
}
|
|
685
|
+
/** Signal-extraction mode: only the turns around keyword-bearing prompts. */
|
|
686
|
+
function formatSignalEntries(session, sessionId, cwd) {
|
|
687
|
+
toolUseMap = /* @__PURE__ */ new Map();
|
|
688
|
+
currentIncludeList = getIncludeTools(cwd);
|
|
689
|
+
const { keywords, turnsBefore } = getSignalConfig(cwd);
|
|
690
|
+
const entries = newEntries(session, sessionId);
|
|
691
|
+
if (entries.length === 0) return null;
|
|
692
|
+
const turns = groupEntriesIntoSignalTurns(entries);
|
|
693
|
+
if (turns.length === 0) return null;
|
|
694
|
+
const signalIndices = findSignalTurnIndices(turns, keywords);
|
|
695
|
+
if (signalIndices.length === 0) return null;
|
|
696
|
+
const turnsToFormat = getTurnsAroundSignals(turns, signalIndices, turnsBefore);
|
|
697
|
+
if (turnsToFormat.length === 0) return null;
|
|
698
|
+
const allEntriesToFormat = turnsToFormat.flatMap((turn) => turn.allEntries);
|
|
699
|
+
if (allEntriesToFormat.length === 0) return null;
|
|
700
|
+
const result = envelope(allEntriesToFormat, formatEntryTextOnly);
|
|
701
|
+
if (result.length < 100) return null;
|
|
702
|
+
return {
|
|
703
|
+
formatted: result,
|
|
704
|
+
lastSeq: entries[entries.length - 1].seq
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
/** Default mode: every new user and assistant entry since the last capture. */
|
|
708
|
+
function formatNewEntries(session, sessionId, cwd) {
|
|
709
|
+
toolUseMap = /* @__PURE__ */ new Map();
|
|
710
|
+
currentIncludeList = getIncludeTools(cwd);
|
|
711
|
+
const entries = newEntries(session, sessionId);
|
|
712
|
+
if (entries.length === 0) return null;
|
|
713
|
+
const result = envelope(entries, (entry) => formatEntry(entry) || null);
|
|
714
|
+
if (result.length < 100) return null;
|
|
715
|
+
return {
|
|
716
|
+
formatted: result,
|
|
717
|
+
lastSeq: entries[entries.length - 1].seq
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
//#endregion
|
|
721
|
+
//#region src/capture.ts
|
|
722
|
+
/**
|
|
723
|
+
* `agent/turn-stopping` is serial and awaited, so the save completes before the
|
|
724
|
+
* turn commits. Claude Code runs the same work in an async `Stop` hook; here it
|
|
725
|
+
* is a first-class part of closing the turn.
|
|
726
|
+
*/
|
|
727
|
+
function registerCapture(ctx, rt, config) {
|
|
728
|
+
ctx.on("agent/turn-stopping", async ({ agent }) => {
|
|
729
|
+
if (isSubagent(agent) && !config.includeSubagents) return;
|
|
730
|
+
const settings = loadSettings();
|
|
731
|
+
const sessionId = sessionIdOf(agent);
|
|
732
|
+
try {
|
|
733
|
+
const cwd = cwdOf(agent);
|
|
734
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
735
|
+
if (!sessionId) return;
|
|
736
|
+
let apiKey;
|
|
737
|
+
try {
|
|
738
|
+
apiKey = getApiKey(cwd, projectConfig);
|
|
739
|
+
} catch {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const session = agent.session;
|
|
743
|
+
const delta = getSignalConfig(cwd).enabled ? formatSignalEntries(session, sessionId, cwd) : formatNewEntries(session, sessionId, cwd);
|
|
744
|
+
if (!delta) {
|
|
745
|
+
debugLog(settings, "No new content to save");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const baseUrl = getBaseUrl(cwd, projectConfig);
|
|
749
|
+
const containerTag = getContainerTag(cwd);
|
|
750
|
+
const captured = readState(sessionId).capture?.count || 0;
|
|
751
|
+
writeState(sessionId, "capture", {
|
|
752
|
+
status: "saving",
|
|
753
|
+
count: captured
|
|
754
|
+
});
|
|
755
|
+
const result = await addMemory(baseUrl, apiKey, delta.formatted, containerTag, {
|
|
756
|
+
type: "session_turn",
|
|
757
|
+
project: getProjectName(cwd),
|
|
758
|
+
sm_project_id: getProjectIdentity(cwd),
|
|
759
|
+
sm_scope: "personal",
|
|
760
|
+
sm_capture_mode: "automatic",
|
|
761
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
762
|
+
}, {
|
|
763
|
+
customId: sessionId,
|
|
764
|
+
entityContext: AGENT_ENTITY_CONTEXT
|
|
765
|
+
});
|
|
766
|
+
setLastCapturedSeq(sessionId, delta.lastSeq);
|
|
767
|
+
writeState(sessionId, "capture", {
|
|
768
|
+
status: "saved",
|
|
769
|
+
count: captured + 1
|
|
770
|
+
});
|
|
771
|
+
if (result?.id) try {
|
|
772
|
+
saveLastSession({
|
|
773
|
+
id: result.id,
|
|
774
|
+
containerTag
|
|
775
|
+
});
|
|
776
|
+
} catch {}
|
|
777
|
+
debugLog(settings, "Session turn saved", { length: delta.formatted.length });
|
|
778
|
+
} catch (err) {
|
|
779
|
+
const friendly = getUserFriendlyError(err);
|
|
780
|
+
debugLog(settings, "Capture error", { error: friendly });
|
|
781
|
+
rt.warn(friendly);
|
|
782
|
+
writeState(sessionId, "capture", { status: "error" });
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
//#endregion
|
|
787
|
+
//#region src/context-gatherer.ts
|
|
788
|
+
const SKILL_NAME = "supermemory-context-gatherer";
|
|
789
|
+
/**
|
|
790
|
+
* The skill ships at the package root; the built bundle sits one level down
|
|
791
|
+
* and the sources one as well, so both candidates are probed.
|
|
792
|
+
*/
|
|
793
|
+
function resolveSkillDir() {
|
|
794
|
+
const candidates = [`../skills/${SKILL_NAME}/`, `../../skills/${SKILL_NAME}/`].map((relative) => fileURLToPath(new URL(relative, import.meta.url)));
|
|
795
|
+
return candidates.find((dir) => fs.existsSync(path.join(dir, "SKILL.md"))) ?? candidates[0];
|
|
796
|
+
}
|
|
797
|
+
/** Strip the YAML frontmatter block a filesystem skill provider would parse. */
|
|
798
|
+
function splitFrontmatter(source) {
|
|
799
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
|
|
800
|
+
if (!match) return {
|
|
801
|
+
data: {},
|
|
802
|
+
body: source.trim()
|
|
803
|
+
};
|
|
804
|
+
const data = {};
|
|
805
|
+
for (const line of (match[1] ?? "").split(/\r?\n/)) {
|
|
806
|
+
const separator = line.indexOf(":");
|
|
807
|
+
if (separator === -1) continue;
|
|
808
|
+
data[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
data,
|
|
812
|
+
body: source.slice(match[0].length).trim()
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* The Claude Code plugin ships this as a subagent definition under `agents/`.
|
|
817
|
+
* DSH has no markdown agent loader, so the same instructions ship as a skill:
|
|
818
|
+
* one file, discovered by name, invocable by the model or by the user.
|
|
819
|
+
*/
|
|
820
|
+
function registerContextGatherer(ctx, rt) {
|
|
821
|
+
const skillDir = resolveSkillDir();
|
|
822
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
823
|
+
let source;
|
|
824
|
+
try {
|
|
825
|
+
source = fs.readFileSync(skillFile, "utf-8");
|
|
826
|
+
} catch (err) {
|
|
827
|
+
rt.warn(`context-gatherer skill not registered — ${skillFile} is unreadable: ${err.message}`);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const { data, body } = splitFrontmatter(source);
|
|
831
|
+
const description = data.description;
|
|
832
|
+
if (!description || !body) {
|
|
833
|
+
rt.warn(`context-gatherer skill not registered — ${skillFile} is missing a description or body`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
ctx.inject(["skills"], (skillCtx) => {
|
|
837
|
+
skillCtx.skills.register({
|
|
838
|
+
name: SKILL_NAME,
|
|
839
|
+
description,
|
|
840
|
+
content: body,
|
|
841
|
+
source: "runtime",
|
|
842
|
+
path: skillFile,
|
|
843
|
+
resourceBase: {
|
|
844
|
+
kind: "directory",
|
|
845
|
+
path: skillDir
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/recall.ts
|
|
852
|
+
const MIN_PROMPT_LENGTH = 12;
|
|
853
|
+
const MAX_QUERY_LENGTH = 500;
|
|
854
|
+
const MAX_RESULTS = 5;
|
|
855
|
+
const MAX_RESULT_CHARS = 300;
|
|
856
|
+
const MIN_SIMILARITY = .55;
|
|
857
|
+
const SEARCH_TIMEOUT_MS = 3e3;
|
|
858
|
+
function shouldSkip(prompt) {
|
|
859
|
+
if (prompt.length < MIN_PROMPT_LENGTH) return true;
|
|
860
|
+
return [
|
|
861
|
+
"/",
|
|
862
|
+
"!",
|
|
863
|
+
"#"
|
|
864
|
+
].includes(prompt[0] ?? "");
|
|
865
|
+
}
|
|
866
|
+
function resultText(r) {
|
|
867
|
+
return [
|
|
868
|
+
r?.memory,
|
|
869
|
+
r?.chunk,
|
|
870
|
+
r?.content,
|
|
871
|
+
r?.text
|
|
872
|
+
].find((v) => typeof v === "string" && v.trim().length > 0) || null;
|
|
873
|
+
}
|
|
874
|
+
function hashText(text) {
|
|
875
|
+
return crypto.createHash("sha256").update(text.replace(/\s+/g, " ").trim()).digest("hex").slice(0, 16);
|
|
876
|
+
}
|
|
877
|
+
function readSeenHashes(sessionDir) {
|
|
878
|
+
try {
|
|
879
|
+
const list = JSON.parse(fs.readFileSync(path.join(sessionDir, "recalled.json"), "utf8"));
|
|
880
|
+
return Array.isArray(list) ? list.filter((h) => typeof h === "string") : [];
|
|
881
|
+
} catch {
|
|
882
|
+
return [];
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
function formatRecall(results, containerTag) {
|
|
886
|
+
return `<supermemory-recall>
|
|
887
|
+
◪ Recalled from supermemory for this prompt (relevance-ranked):
|
|
888
|
+
${results.map((r) => {
|
|
889
|
+
const text = (resultText(r) ?? "").replace(/\s+/g, " ").slice(0, MAX_RESULT_CHARS);
|
|
890
|
+
const title = typeof r.title === "string" && r.title.trim() ? r.title.trim() : null;
|
|
891
|
+
return `- ◪ ${title && !text.startsWith(title) ? `${title} — ` : ""}${text}${typeof r.filepath === "string" && r.filepath ? ` (${r.filepath})` : ""}`;
|
|
892
|
+
}).join("\n")}
|
|
893
|
+
|
|
894
|
+
When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool (containerTag: "${containerTag}") or launch the supermemory-context-gatherer skill.
|
|
895
|
+
</supermemory-recall>`;
|
|
896
|
+
}
|
|
897
|
+
/** Memory is material lifted out of earlier sessions, which is exactly `form: 'recall'`. */
|
|
898
|
+
function contextMessage(text) {
|
|
899
|
+
return createUserMessage({
|
|
900
|
+
content: [{
|
|
901
|
+
type: "text",
|
|
902
|
+
text
|
|
903
|
+
}],
|
|
904
|
+
source: {
|
|
905
|
+
...PLUGIN_SOURCE,
|
|
906
|
+
form: "recall"
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
/** Flatten the direct human prompt out of the messages claimed for this step. */
|
|
911
|
+
function promptFrom(messages) {
|
|
912
|
+
return messages.filter((message) => message.source.kind === "user").flatMap((message) => message.content).filter((block) => block.type === "text").map((block) => block.text).join("").trim();
|
|
913
|
+
}
|
|
914
|
+
async function recallFor(rt, cwd, sessionId, prompt) {
|
|
915
|
+
const settings = loadSettings();
|
|
916
|
+
try {
|
|
917
|
+
const { directive } = getRecallConfig(cwd);
|
|
918
|
+
if (directive) return directive;
|
|
919
|
+
if (shouldSkip(prompt)) return null;
|
|
920
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
921
|
+
let apiKey;
|
|
922
|
+
try {
|
|
923
|
+
apiKey = getApiKey(cwd, projectConfig);
|
|
924
|
+
} catch {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
const containerTag = getContainerTag(cwd);
|
|
928
|
+
const results = ((await getProfile(getBaseUrl(cwd, projectConfig), apiKey, containerTag, prompt.slice(0, MAX_QUERY_LENGTH), { timeoutMs: SEARCH_TIMEOUT_MS }))?.searchResults?.results || []).filter((r) => resultText(r)).filter((r) => !Number.isFinite(r.similarity) || r.similarity >= MIN_SIMILARITY).slice(0, MAX_RESULTS);
|
|
929
|
+
const sessionDir = getSessionDir(sessionId);
|
|
930
|
+
const seen = sessionDir ? readSeenHashes(sessionDir) : [];
|
|
931
|
+
const seenSet = new Set(seen);
|
|
932
|
+
const fresh = results.filter((r) => !seenSet.has(hashText(resultText(r))));
|
|
933
|
+
const repeats = results.length - fresh.length;
|
|
934
|
+
if (sessionId) {
|
|
935
|
+
const prev = readState(sessionId).search;
|
|
936
|
+
writeState(sessionId, "search", {
|
|
937
|
+
results: fresh.length,
|
|
938
|
+
count: (prev?.count ?? 0) + 1,
|
|
939
|
+
memories: (prev?.memories ?? 0) + fresh.length
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
debugLog(settings, "Prompt recall", {
|
|
943
|
+
query: prompt.slice(0, 80),
|
|
944
|
+
hits: results.length,
|
|
945
|
+
fresh: fresh.length
|
|
946
|
+
});
|
|
947
|
+
if (fresh.length === 0) return null;
|
|
948
|
+
if (sessionDir) try {
|
|
949
|
+
atomicWriteJson(path.join(sessionDir, "recalled.json"), [...seen, ...fresh.map((r) => hashText(resultText(r)))].slice(-500));
|
|
950
|
+
} catch {}
|
|
951
|
+
const context = formatRecall(fresh, containerTag);
|
|
952
|
+
const tok = gray(`(${Math.round(context.length / 4)} tok)`);
|
|
953
|
+
rt.notify(repeats ? `recalled ${fresh.length} new ${tok}${gray(` · ${repeats} already in context`)}` : `recalled ${fresh.length} ${fresh.length === 1 ? "memory" : "memories"} ${tok}`);
|
|
954
|
+
return context;
|
|
955
|
+
} catch (err) {
|
|
956
|
+
debugLog(settings, "Recall directive error", { error: err.message });
|
|
957
|
+
rt.notify(red(`recall failed: ${getUserFriendlyError(err).slice(0, 80)}`));
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* One `agent/pre-step` listener owns both injections. It delegates first so a
|
|
963
|
+
* later listener may still reject or rewrite the step, then folds the session
|
|
964
|
+
* bootstrap (once) and this prompt's recall onto the resulting `enter`.
|
|
965
|
+
*/
|
|
966
|
+
function registerRecall(ctx, rt, config) {
|
|
967
|
+
ctx.on("agent/pre-step", async ({ agent, messages }, next) => {
|
|
968
|
+
if (isSubagent(agent) && !config.includeSubagents) return next();
|
|
969
|
+
const sessionId = sessionIdOf(agent);
|
|
970
|
+
const cwd = cwdOf(agent);
|
|
971
|
+
const prompt = promptFrom(messages);
|
|
972
|
+
const pending = config.injectProfile !== false && !rt.delivered.has(sessionId) ? rt.bootstraps.get(sessionId) : void 0;
|
|
973
|
+
const bootstrapText = pending ? await pending : null;
|
|
974
|
+
const recallText = config.recall !== false && prompt.length > 0 ? await recallFor(rt, cwd, sessionId, prompt) : null;
|
|
975
|
+
const downstream = await next();
|
|
976
|
+
if (downstream.kind !== "enter") return downstream;
|
|
977
|
+
const additions = [];
|
|
978
|
+
if (bootstrapText) {
|
|
979
|
+
rt.delivered.add(sessionId);
|
|
980
|
+
additions.push(contextMessage(bootstrapText));
|
|
981
|
+
}
|
|
982
|
+
if (recallText) additions.push(contextMessage(recallText));
|
|
983
|
+
if (additions.length === 0) return downstream;
|
|
984
|
+
return {
|
|
985
|
+
...downstream,
|
|
986
|
+
messages: [...downstream.messages, ...additions]
|
|
987
|
+
};
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/session-start.ts
|
|
992
|
+
const STATUSLINE_TIP_FILE = path.join(os.homedir(), ".supermemory-claude", "statusline-tip-shown");
|
|
993
|
+
const MARK_TIP_FILE = path.join(os.homedir(), ".supermemory-claude", "mark-tip-shown");
|
|
994
|
+
/**
|
|
995
|
+
* DSH owns its status line, so there is nothing to install into a setting the
|
|
996
|
+
* way the Claude Code plugin installs one. The per-session state files are
|
|
997
|
+
* written all the same, and this one-time tip points at the renderer that
|
|
998
|
+
* turns them into the identical line.
|
|
999
|
+
*/
|
|
1000
|
+
function statuslineTip() {
|
|
1001
|
+
try {
|
|
1002
|
+
if (fs.existsSync(STATUSLINE_TIP_FILE)) return null;
|
|
1003
|
+
fs.mkdirSync(path.dirname(STATUSLINE_TIP_FILE), { recursive: true });
|
|
1004
|
+
fs.writeFileSync(STATUSLINE_TIP_FILE, (/* @__PURE__ */ new Date()).toISOString());
|
|
1005
|
+
return `${MARK} supermemory status is live — render it anywhere with \`node -e "require('dsh-supermemory/statusline')" <<< '{"session_id":"…"}'\`, or read ~/.supermemory-claude/statusline.`;
|
|
1006
|
+
} catch {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
function markTip() {
|
|
1011
|
+
try {
|
|
1012
|
+
if (fs.existsSync(MARK_TIP_FILE)) return null;
|
|
1013
|
+
fs.mkdirSync(path.dirname(MARK_TIP_FILE), { recursive: true });
|
|
1014
|
+
fs.writeFileSync(MARK_TIP_FILE, (/* @__PURE__ */ new Date()).toISOString());
|
|
1015
|
+
return `${MARK} is the supermemory mark — whenever you see it (status, notices, the assistant's answers), that information came from supermemory.`;
|
|
1016
|
+
} catch {
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
function welcomeBackNotice(containerTag) {
|
|
1021
|
+
try {
|
|
1022
|
+
const last = JSON.parse(fs.readFileSync(LAST_SESSION_FILE, "utf-8"));
|
|
1023
|
+
if (!last.savedAt || last.containerTag !== containerTag) return null;
|
|
1024
|
+
const hours = (Date.now() - new Date(last.savedAt).getTime()) / 36e5;
|
|
1025
|
+
if (hours < 6) return null;
|
|
1026
|
+
return `welcome back — last session here ${hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`}`;
|
|
1027
|
+
} catch {
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function formatContext(profileResult, maxItems, containerTag, projectName) {
|
|
1032
|
+
const statics = (profileResult?.profile?.static || []).slice(0, maxItems);
|
|
1033
|
+
const dynamics = (profileResult?.profile?.dynamic || []).slice(0, maxItems);
|
|
1034
|
+
if (statics.length === 0 && dynamics.length === 0) return null;
|
|
1035
|
+
const sections = [];
|
|
1036
|
+
if (statics.length > 0) sections.push(`## User Profile (Persistent)\n${statics.map((f) => `- ◪ ${f}`).join("\n")}`);
|
|
1037
|
+
if (dynamics.length > 0) sections.push(`## Recent Context\n${dynamics.map((f) => `- ◪ ${f}`).join("\n")}`);
|
|
1038
|
+
return `<supermemory-context>
|
|
1039
|
+
Recalled memory for this project (${projectName}). Every line marked ◪ comes from supermemory — when citing one, keep the mark and phrase it naturally (e.g. "◪ last week you told me about X"). If you name the source, say "from supermemory" — never "from memory".
|
|
1040
|
+
This project's memory container: ${containerTag}
|
|
1041
|
+
|
|
1042
|
+
${sections.join("\n\n")}
|
|
1043
|
+
</supermemory-context>`;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Resolve the memory context for one session. Returns the exact text the
|
|
1047
|
+
* Claude Code plugin's SessionStart hook would have produced as
|
|
1048
|
+
* `additionalContext`, or null when there is nothing to say.
|
|
1049
|
+
*/
|
|
1050
|
+
async function bootstrap(rt, config, cwd, sessionId) {
|
|
1051
|
+
const settings = loadSettings();
|
|
1052
|
+
try {
|
|
1053
|
+
pruneState({ dataDir: resolveStatuslineDataDir() });
|
|
1054
|
+
writeState(sessionId, "context", {
|
|
1055
|
+
status: "loading",
|
|
1056
|
+
memoryItemsLoaded: 0
|
|
1057
|
+
});
|
|
1058
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
1059
|
+
const projectName = getProjectName(cwd);
|
|
1060
|
+
const containerTag = getContainerTag(cwd);
|
|
1061
|
+
debugLog(settings, "SessionStart", {
|
|
1062
|
+
cwd,
|
|
1063
|
+
projectName,
|
|
1064
|
+
containerTag
|
|
1065
|
+
});
|
|
1066
|
+
let apiKey;
|
|
1067
|
+
try {
|
|
1068
|
+
apiKey = getApiKey(cwd, projectConfig);
|
|
1069
|
+
} catch {
|
|
1070
|
+
if (!config.browserLogin) {
|
|
1071
|
+
writeState(sessionId, "context", {
|
|
1072
|
+
status: "error",
|
|
1073
|
+
memoryItemsLoaded: 0
|
|
1074
|
+
});
|
|
1075
|
+
return `<supermemory-status>
|
|
1076
|
+
Supermemory is not authenticated and browser login is disabled for this deployment.
|
|
1077
|
+
Set the SUPERMEMORY_CC_API_KEY environment variable, or authenticate at: ${AUTH_BASE_URL}
|
|
1078
|
+
</supermemory-status>`;
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
apiKey = await startAuthFlow();
|
|
1082
|
+
} catch (authErr) {
|
|
1083
|
+
writeState(sessionId, "context", {
|
|
1084
|
+
status: "error",
|
|
1085
|
+
memoryItemsLoaded: 0
|
|
1086
|
+
});
|
|
1087
|
+
return `<supermemory-status>
|
|
1088
|
+
${authErr.message === "AUTH_TIMEOUT" ? "Authentication timed out. Please complete login in the browser window." : "Authentication failed."}
|
|
1089
|
+
If the browser did not open, visit: ${AUTH_BASE_URL}
|
|
1090
|
+
Or set the SUPERMEMORY_CC_API_KEY environment variable.
|
|
1091
|
+
</supermemory-status>`;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
const baseUrl = getBaseUrl(cwd, projectConfig);
|
|
1095
|
+
let profileResult = null;
|
|
1096
|
+
let apiError = null;
|
|
1097
|
+
try {
|
|
1098
|
+
profileResult = await getProfile(baseUrl, apiKey, containerTag, projectName);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
if (err?.status !== 404) apiError = getUserFriendlyError(err);
|
|
1101
|
+
debugLog(settings, "Profile fetch failed", { error: err.message });
|
|
1102
|
+
}
|
|
1103
|
+
const context = formatContext(profileResult, settings.maxProfileItems, containerTag, projectName);
|
|
1104
|
+
const loaded = Math.min(profileResult?.profile?.static?.length || 0, settings.maxProfileItems) + Math.min(profileResult?.profile?.dynamic?.length || 0, settings.maxProfileItems);
|
|
1105
|
+
writeState(sessionId, "context", {
|
|
1106
|
+
status: apiError ? "error" : "ready",
|
|
1107
|
+
memoryItemsLoaded: loaded
|
|
1108
|
+
});
|
|
1109
|
+
const banner = [loaded > 0 ? `${loaded} ${loaded === 1 ? "memory" : "memories"} loaded for ${bold(projectName)}` : null, welcomeBackNotice(containerTag)].filter(Boolean).join(gray(" · "));
|
|
1110
|
+
if (banner) rt.notify(banner);
|
|
1111
|
+
for (const tip of [markTip(), statuslineTip()]) if (tip) rt.notify(tip);
|
|
1112
|
+
return (apiError ? `<supermemory-status>\n${apiError}\n</supermemory-status>\n` : "") + (context || (apiError ? `<supermemory-context>
|
|
1113
|
+
Memory could not be loaded this session — do not assume this project has no memories.
|
|
1114
|
+
</supermemory-context>` : `<supermemory-context>
|
|
1115
|
+
No previous memories found for this project (container: ${containerTag}).
|
|
1116
|
+
Memories will be saved as you work.
|
|
1117
|
+
</supermemory-context>`));
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
const friendly = getUserFriendlyError(err);
|
|
1120
|
+
rt.warn(friendly);
|
|
1121
|
+
writeState(sessionId, "context", {
|
|
1122
|
+
status: "error",
|
|
1123
|
+
memoryItemsLoaded: 0
|
|
1124
|
+
});
|
|
1125
|
+
return `<supermemory-status>
|
|
1126
|
+
Failed to load memories: ${friendly}
|
|
1127
|
+
Session will continue without memory context.
|
|
1128
|
+
</supermemory-status>`;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
function registerSessionStart(ctx, rt, config) {
|
|
1132
|
+
ctx.on("agent/session-start", ({ agent }) => {
|
|
1133
|
+
const sessionId = sessionIdOf(agent);
|
|
1134
|
+
if (!sessionId || rt.bootstraps.has(sessionId)) return;
|
|
1135
|
+
if (isSubagent(agent) && !config.includeSubagents) return;
|
|
1136
|
+
rt.bootstraps.set(sessionId, bootstrap(rt, config, cwdOf(agent), sessionId).catch((err) => {
|
|
1137
|
+
rt.warn(`session bootstrap failed: ${getUserFriendlyError(err)}`);
|
|
1138
|
+
return null;
|
|
1139
|
+
}));
|
|
1140
|
+
});
|
|
1141
|
+
ctx.on("agent/disposed", ({ agent }) => {
|
|
1142
|
+
const sessionId = sessionIdOf(agent);
|
|
1143
|
+
rt.bootstraps.delete(sessionId);
|
|
1144
|
+
rt.delivered.delete(sessionId);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/status.ts
|
|
1149
|
+
const PROBE_TIMEOUT_MS = 8e3;
|
|
1150
|
+
/** Never print a full key: at most the first 6 and last 4 characters. */
|
|
1151
|
+
function maskKey(key) {
|
|
1152
|
+
if (key.length <= 10) return `${key.slice(0, 2)}…`;
|
|
1153
|
+
return `${key.slice(0, 6)}…${key.slice(-4)}`;
|
|
1154
|
+
}
|
|
1155
|
+
function resolveKey(cwd) {
|
|
1156
|
+
if (process.env.SUPERMEMORY_CC_API_KEY) return {
|
|
1157
|
+
key: process.env.SUPERMEMORY_CC_API_KEY,
|
|
1158
|
+
source: "env SUPERMEMORY_CC_API_KEY"
|
|
1159
|
+
};
|
|
1160
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
1161
|
+
if (projectConfig?.apiKey) return {
|
|
1162
|
+
key: projectConfig.apiKey,
|
|
1163
|
+
source: ".claude/.supermemory-claude/config.json"
|
|
1164
|
+
};
|
|
1165
|
+
const credentials = loadCredentials();
|
|
1166
|
+
if (credentials?.apiKey) return {
|
|
1167
|
+
key: credentials.apiKey,
|
|
1168
|
+
source: CREDENTIALS_FILE
|
|
1169
|
+
};
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* A stored key proves nothing by itself, so the report always probes the API.
|
|
1174
|
+
* Interpret loudly: 401/403 is the silent-failure case the probe exists to
|
|
1175
|
+
* catch.
|
|
1176
|
+
*/
|
|
1177
|
+
async function probe(baseUrl, key, containerTag) {
|
|
1178
|
+
try {
|
|
1179
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/v4/profile`, {
|
|
1180
|
+
method: "POST",
|
|
1181
|
+
headers: {
|
|
1182
|
+
Authorization: `Bearer ${key}`,
|
|
1183
|
+
"Content-Type": "application/json",
|
|
1184
|
+
"x-sm-source": "claude-code"
|
|
1185
|
+
},
|
|
1186
|
+
body: JSON.stringify({
|
|
1187
|
+
containerTag,
|
|
1188
|
+
q: "connectivity probe"
|
|
1189
|
+
}),
|
|
1190
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
1191
|
+
});
|
|
1192
|
+
if (response.status === 200) return "reachable — 200, the key works";
|
|
1193
|
+
if (response.status === 401 || response.status === 403) return `reachable, but the key is invalid or revoked — ${response.status}. Re-authenticate at ${AUTH_BASE_URL}`;
|
|
1194
|
+
if (response.status >= 500) return `API error — ${response.status}, service temporarily unavailable`;
|
|
1195
|
+
return `unexpected response — ${response.status}`;
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
const error = err;
|
|
1198
|
+
return `UNREACHABLE — ${error.name === "TimeoutError" ? `timed out after ${PROBE_TIMEOUT_MS}ms` : error.message}`;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
function registerStatusCommand(ctx, config) {
|
|
1202
|
+
ctx.inject(["commands"], (commandCtx) => {
|
|
1203
|
+
commandCtx.commands.register({
|
|
1204
|
+
name: "supermemory-status",
|
|
1205
|
+
description: "Show Supermemory authentication and connection status",
|
|
1206
|
+
recordInput: false,
|
|
1207
|
+
handler: async ({ agent }) => {
|
|
1208
|
+
const cwd = cwdOf(agent);
|
|
1209
|
+
const projectName = getProjectName(cwd);
|
|
1210
|
+
const containerTag = getContainerTag(cwd);
|
|
1211
|
+
const resolved = resolveKey(cwd);
|
|
1212
|
+
const prefix = `mcp__${config.mcpServerName ?? "supermemory"}__`;
|
|
1213
|
+
const mcpTools = (ctx.get("tools")?.schemas(agent.id) ?? []).map((schema) => schema.name).filter((name) => name.startsWith(prefix));
|
|
1214
|
+
const lines = [
|
|
1215
|
+
`${MARK} supermemory`,
|
|
1216
|
+
"",
|
|
1217
|
+
`project ${projectName}`,
|
|
1218
|
+
`container tag ${containerTag}`,
|
|
1219
|
+
`settings ${SETTINGS_FILE}`
|
|
1220
|
+
];
|
|
1221
|
+
if (!resolved) {
|
|
1222
|
+
lines.push("authenticated NO", "", `Start a new session to open the browser login automatically, or set SUPERMEMORY_CC_API_KEY. Login page: ${AUTH_BASE_URL}`);
|
|
1223
|
+
return {
|
|
1224
|
+
kind: "success",
|
|
1225
|
+
text: lines.join("\n")
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
const baseUrl = getBaseUrl(cwd);
|
|
1229
|
+
lines.push("authenticated yes", `key ${maskKey(resolved.key)} (from ${resolved.source})`, `api ${baseUrl}`, `api probe ${await probe(baseUrl, resolved.key, containerTag)}`, `mcp ${mcpTools.length > 0 ? `${mcpTools.length} tool${mcpTools.length === 1 ? "" : "s"} under ${prefix}` : `no ${prefix}* tools registered`}`);
|
|
1230
|
+
return {
|
|
1231
|
+
kind: "success",
|
|
1232
|
+
text: lines.join("\n")
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
//#endregion
|
|
1239
|
+
//#region src/index.ts
|
|
1240
|
+
/**
|
|
1241
|
+
* Persistent memory across DeepSeek Harness sessions using Supermemory.
|
|
1242
|
+
*
|
|
1243
|
+
* A native port of the Claude Code `supermemory` plugin v0.1.6. The four
|
|
1244
|
+
* behaviors its `hooks.json` wires — session-start memory injection, per-prompt
|
|
1245
|
+
* recall, auto-approved read-only memory tools, and turn capture — are mapped
|
|
1246
|
+
* onto DSH's typed extension points instead of command hooks, and the hosted
|
|
1247
|
+
* MCP server is mounted through the same stdio proxy. Credentials, settings,
|
|
1248
|
+
* container tags, and the wire format are shared byte-for-byte with the Claude
|
|
1249
|
+
* Code plugin, so one login and one memory container serve both harnesses.
|
|
1250
|
+
*
|
|
1251
|
+
* @module dsh-supermemory
|
|
1252
|
+
*/
|
|
1253
|
+
const name = "supermemory";
|
|
1254
|
+
/** The bundled stdio bridge to the hosted Supermemory MCP server. */
|
|
1255
|
+
const MCP_PROXY = fileURLToPath(new URL("./mcp-proxy.mjs", import.meta.url));
|
|
1256
|
+
function apply(ctx, config) {
|
|
1257
|
+
const rt = createRuntime(ctx);
|
|
1258
|
+
if (config.injectProfile !== false) registerSessionStart(ctx, rt, config);
|
|
1259
|
+
if (config.injectProfile !== false || config.recall !== false) registerRecall(ctx, rt, config);
|
|
1260
|
+
if (config.autoApprove !== false) registerApprove(ctx, rt, config);
|
|
1261
|
+
if (config.capture !== false) registerCapture(ctx, rt, config);
|
|
1262
|
+
if (config.command !== false) registerStatusCommand(ctx, config);
|
|
1263
|
+
if (config.contextGatherer !== false) registerContextGatherer(ctx, rt);
|
|
1264
|
+
if (config.mcp !== false) ctx.plugin(McpClient, {
|
|
1265
|
+
transport: "stdio",
|
|
1266
|
+
serverName: config.mcpServerName ?? "supermemory",
|
|
1267
|
+
command: process.execPath,
|
|
1268
|
+
args: [MCP_PROXY],
|
|
1269
|
+
env: {},
|
|
1270
|
+
cwd: "",
|
|
1271
|
+
toolCallTimeoutMs: 6e4,
|
|
1272
|
+
failOnStartupError: false,
|
|
1273
|
+
reconnect: {
|
|
1274
|
+
enabled: true,
|
|
1275
|
+
initialDelayMs: 500,
|
|
1276
|
+
maxDelayMs: 3e4,
|
|
1277
|
+
maxAttempts: 10
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
//#endregion
|
|
1282
|
+
export { PluginConfig as Config, PluginConfig, PLUGIN_SOURCE, apply, cleanContent, createRuntime, cwdOf, entriesFromEvents, findSignalTurnIndices, formatContext, formatEntry, formatNewEntries, formatRecall, formatSignalEntries, formatToolInputCompact, getLastCapturedSeq, getTextFromEntry, getTurnsAroundSignals, groupEntriesIntoTurns, hashText, isSubagent, maskKey, name, promptFrom, readOnlyToolOf, resolveSkillDir, resultText, sessionIdOf, setLastCapturedSeq, shouldSkip, splitFrontmatter, truncate };
|