sessionmem 1.3.0 → 1.5.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.
Files changed (68) hide show
  1. package/.github/sessionmem-logo.svg +57 -0
  2. package/CHANGELOG.md +128 -0
  3. package/CONTRIBUTING.md +130 -0
  4. package/README.md +292 -108
  5. package/SECURITY.md +33 -0
  6. package/dist/adapters/diagnostics.js +109 -0
  7. package/dist/adapters/generic.js +6 -2
  8. package/dist/adapters/global/antigravity.js +4 -1
  9. package/dist/adapters/global/claudeCode.js +19 -4
  10. package/dist/adapters/global/codex.js +7 -6
  11. package/dist/adapters/global/qcoder.js +4 -1
  12. package/dist/adapters/ide/cline.js +3 -0
  13. package/dist/adapters/ide/cursor.js +3 -0
  14. package/dist/adapters/ide/installer.js +17 -5
  15. package/dist/adapters/ide/windsurf.js +3 -0
  16. package/dist/adapters/installedHosts.js +51 -0
  17. package/dist/cli/commands/dedupe.js +1 -1
  18. package/dist/cli/commands/doctor.js +146 -0
  19. package/dist/cli/commands/entities.js +27 -0
  20. package/dist/cli/commands/history.js +23 -0
  21. package/dist/cli/commands/import.js +15 -44
  22. package/dist/cli/commands/ingestHook.js +4 -78
  23. package/dist/cli/commands/install.js +37 -59
  24. package/dist/cli/commands/list.js +3 -0
  25. package/dist/cli/commands/run.js +10 -12
  26. package/dist/cli/commands/scan.js +16 -0
  27. package/dist/cli/commands/search.js +9 -4
  28. package/dist/cli/commands/team.js +7 -3
  29. package/dist/cli/commands/ui.js +67 -0
  30. package/dist/cli/commands/uninstall.js +2 -1
  31. package/dist/cli/commands/watch.js +48 -0
  32. package/dist/cli/context.js +8 -6
  33. package/dist/cli/index.js +48 -7
  34. package/dist/cli/projectId.js +1 -2
  35. package/dist/cli/transcriptSweep.js +182 -0
  36. package/dist/core/api/contracts.js +1 -0
  37. package/dist/core/api/memoryCoreService.js +8 -0
  38. package/dist/core/api/sessionLifecycleService.js +13 -1
  39. package/dist/core/capture/toolEvent.js +82 -0
  40. package/dist/core/config/policyConfig.js +5 -1
  41. package/dist/core/extract/factClaims.js +97 -0
  42. package/dist/core/extract/knowledge.js +127 -0
  43. package/dist/core/injection/tokenBudget.js +10 -2
  44. package/dist/core/retrieve/retrieveMemories.js +20 -4
  45. package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
  46. package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
  47. package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
  48. package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
  49. package/dist/core/schema/runMigrations.js +55 -5
  50. package/dist/core/storage/db.js +31 -9
  51. package/dist/core/storage/knowledgeRepo.js +67 -0
  52. package/dist/core/storage/memoryRepo.js +45 -12
  53. package/dist/core/storage/memorySearchRepo.js +30 -20
  54. package/dist/core/storage/supersessionRepo.js +101 -0
  55. package/dist/core/summarize/cloudSummarizer.js +3 -8
  56. package/dist/core/summarize/summaryShape.js +1 -1
  57. package/dist/ui/assets.js +181 -0
  58. package/dist/ui/server.js +147 -0
  59. package/docs/REGISTRY_LISTING.md +88 -0
  60. package/docs/architecture.md +77 -0
  61. package/docs/benchmark.md +122 -0
  62. package/docs/cloud-summarization.md +57 -0
  63. package/docs/migration.md +44 -0
  64. package/docs/privacy-and-retention.md +139 -0
  65. package/docs/team-mode.md +117 -0
  66. package/docs/troubleshooting.md +126 -0
  67. package/docs/typescript-upgrade.md +48 -0
  68. package/package.json +25 -13
package/SECURITY.md ADDED
@@ -0,0 +1,33 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are provided for the latest published stable release of
6
+ sessionmem. Older releases and prereleases are not supported;
7
+ upgrade to the latest stable release before checking whether an issue persists.
8
+ The main branch receives fixes for the next release but is not a supported
9
+ published version.
10
+
11
+ ## Report a vulnerability privately
12
+
13
+ Use [GitHub private vulnerability reporting](https://github.com/catfish-1234/sessionmem/security/advisories/new)
14
+ to open a private security advisory. You need a GitHub account. Reports stay
15
+ private while maintainers investigate and coordinate a fix with the reporter.
16
+ Do not disclose vulnerabilities in public issues, pull requests, or discussions.
17
+
18
+ Include the affected version, operating system and Node.js version, reproduction
19
+ steps or a minimal proof of concept, expected impact, and any suggested mitigation.
20
+ Use synthetic data: do not include real API keys, private session transcripts,
21
+ production memory databases, or other sensitive information.
22
+
23
+ ## Response expectations
24
+
25
+ Maintainers aim to acknowledge reports within 7 calendar days and provide an
26
+ initial assessment within 14 calendar days. These are best-effort targets for a
27
+ community-maintained project, not guaranteed service levels. If you have not
28
+ heard back after 7 days, follow up in the same private advisory.
29
+
30
+ For confirmed vulnerabilities, maintainers coordinate remediation, disclosure
31
+ timing, and reporter credit in the private advisory. Fix timing depends on
32
+ severity and complexity; maintainers will share progress and available
33
+ workarounds there. Keep details private until coordinated disclosure.
@@ -0,0 +1,109 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { IDEInstaller, isManagedClaudeHook } from "./ide/installer.js";
6
+ import { resolveDbPath } from "../cli/context.js";
7
+ function object(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value)
9
+ ? value
10
+ : {};
11
+ }
12
+ export function checkHooks(installation) {
13
+ const expected = installation.hooks;
14
+ if (!expected)
15
+ return "This host uses MCP tools; no lifecycle hooks are required";
16
+ const config = IDEInstaller.parseJsonc(readFileSync(expected.path, "utf8"));
17
+ if (config.disableAllHooks === true)
18
+ throw new Error("Claude Code has disableAllHooks enabled");
19
+ const hooks = object(config.hooks);
20
+ for (const spec of expected.entries) {
21
+ const entries = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
22
+ const managed = entries.flatMap((value) => {
23
+ const entry = object(value);
24
+ return Array.isArray(entry.hooks) ? entry.hooks.filter((value) => isManagedClaudeHook(object(value).command, spec.command)).map((hook) => ({ entry, hook: object(hook) })) : [];
25
+ });
26
+ const matches = managed.filter(({ entry, hook }) => {
27
+ if (entry.matcher !== spec.matcher || entry.async === true)
28
+ return false;
29
+ return hook.type === "command" && hook.command === spec.command && hook.async !== true;
30
+ });
31
+ if (managed.length !== 1 || matches.length !== 1)
32
+ throw new Error(`${spec.event} hook is missing, duplicated, or differs from the current definition`);
33
+ }
34
+ return `SessionStart, SessionEnd, PostToolUse match current hook definitions (${expected.path})`;
35
+ }
36
+ /** Check the installed launch entry before executing the known local command. */
37
+ export function readMcpLaunch(installation) {
38
+ const expected = installation.mcp;
39
+ const content = readFileSync(expected.path, "utf8");
40
+ let entry;
41
+ if (expected.format === "toml") {
42
+ // The Codex installer writes this small TOML section with JSON-compatible
43
+ // quoted values. Refuse unfamiliar syntax rather than claiming it works.
44
+ const section = content.match(/^\s*\[mcp_servers\.sessionmem\][^\S\r\n]*\r?\n([\s\S]*?)(?=^\s*\[|$(?![\s\S]))/m)?.[1];
45
+ if (!section)
46
+ throw new Error("sessionmem MCP section is missing");
47
+ if (/^\s*\[mcp_servers\.sessionmem\./m.test(content) || /^\s*(?:env|env_vars|cwd|url)\s*=/m.test(section)) {
48
+ throw new Error("Custom Codex launch settings require manual verification; doctor supports the installer-generated command and args");
49
+ }
50
+ if (/^\s*enabled\s*=\s*false\s*$/m.test(section))
51
+ throw new Error("sessionmem MCP server is disabled");
52
+ entry = {
53
+ command: JSON.parse(section.match(/^\s*command\s*=\s*(.+)$/m)?.[1] ?? "null"),
54
+ args: JSON.parse(section.match(/^\s*args\s*=\s*(.+)$/m)?.[1] ?? "null"),
55
+ };
56
+ }
57
+ else {
58
+ const config = IDEInstaller.parseJsonc(content);
59
+ entry = object(object(config.mcpServers).sessionmem);
60
+ }
61
+ if (entry.disabled === true)
62
+ throw new Error("sessionmem MCP server is disabled");
63
+ if (entry.type !== undefined && entry.type !== "stdio")
64
+ throw new Error("sessionmem MCP server must use the local stdio transport");
65
+ if (entry.command !== expected.command || JSON.stringify(entry.args) !== JSON.stringify(expected.args)) {
66
+ throw new Error(`sessionmem MCP launch entry differs from the current definition (${expected.path})`);
67
+ }
68
+ if (entry.env !== undefined && (entry.env === null || typeof entry.env !== "object" || Array.isArray(entry.env))) {
69
+ throw new Error("sessionmem MCP environment must be an object");
70
+ }
71
+ const env = object(entry.env);
72
+ if (Object.values(env).some((value) => typeof value !== "string")) {
73
+ throw new Error("sessionmem MCP environment values must be strings");
74
+ }
75
+ return { command: expected.command, args: expected.args, env: env };
76
+ }
77
+ export async function probeMcpWiring(launch, dbPath, version) {
78
+ if (launch.env?.SESSIONMEM_DB_PATH && resolve(resolveDbPath(launch.env.SESSIONMEM_DB_PATH)) !== resolve(dbPath)) {
79
+ throw new Error("MCP configuration selects a different SESSIONMEM_DB_PATH; run doctor with that path");
80
+ }
81
+ const inherited = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
82
+ const client = new Client({ name: "sessionmem-doctor", version });
83
+ const transport = new StdioClientTransport({
84
+ command: launch.command,
85
+ args: launch.args,
86
+ env: { ...inherited, ...launch.env, SESSIONMEM_DB_PATH: dbPath },
87
+ stderr: "ignore",
88
+ });
89
+ try {
90
+ await client.connect(transport, { timeout: 5000 });
91
+ const server = client.getServerVersion();
92
+ if (server?.name !== "sessionmem" || server.version !== version) {
93
+ throw new Error(`MCP server version does not match sessionmem ${version}`);
94
+ }
95
+ const tools = await client.listTools({}, { timeout: 5000 });
96
+ for (const name of ["stats", "storeMemory", "retrieveMemories"]) {
97
+ if (!tools.tools.some((tool) => tool.name === name))
98
+ throw new Error(`MCP tool ${name} is missing`);
99
+ }
100
+ const result = await client.callTool({ name: "stats", arguments: {} }, undefined, { timeout: 5000 });
101
+ const payload = Array.isArray(result.content) ? object(result.content.find((part) => object(part).type === "text")) : {};
102
+ if (result.isError || typeof payload.text !== "string" || !JSON.parse(payload.text).ok) {
103
+ throw new Error("MCP stats tool failed");
104
+ }
105
+ }
106
+ finally {
107
+ await transport.close();
108
+ }
109
+ }
@@ -69,6 +69,7 @@ const TOOL_DEFINITIONS = [
69
69
  mode: retrieveMemoriesRequestSchema.shape.mode.describe("'auto' for the standard startup context-load path. 'on-demand' for an explicit mid-session retrieval triggered by a specific task or question."),
70
70
  depth: retrieveMemoriesRequestSchema.shape.depth.describe("'default' for standard semantic search. 'deep' for a broader sweep that surfaces less-similar memories — use when the topic is new or unfamiliar."),
71
71
  tags: retrieveMemoriesRequestSchema.shape.tags.describe("Optional tag filter. When provided, only return memories that have ALL listed tags."),
72
+ includeHistory: retrieveMemoriesRequestSchema.shape.includeHistory.describe("Include superseded facts for historical questions. Omit for current facts only."),
72
73
  },
73
74
  },
74
75
  {
@@ -209,18 +210,21 @@ export class GenericMCPAdapter {
209
210
  guidanceTargets() {
210
211
  return [join(process.cwd(), "AGENTS.md")];
211
212
  }
213
+ installation() {
214
+ return { mcp: { path: join(process.cwd(), ".mcp.json"), command: "sessionmem", args: ["run"] } };
215
+ }
212
216
  /**
213
217
  * Fallback for hosts that aren't specifically detected: register sessionmem
214
218
  * in a project-local `.mcp.json` (the de-facto generic MCP config format).
215
219
  */
216
220
  async install() {
217
- const configPath = join(process.cwd(), ".mcp.json");
221
+ const configPath = this.installation().mcp.path;
218
222
  return IDEInstaller.injectMcpConfig(configPath, "sessionmem", "sessionmem", [
219
223
  "run",
220
224
  ]);
221
225
  }
222
226
  async uninstall() {
223
- const configPath = join(process.cwd(), ".mcp.json");
227
+ const configPath = this.installation().mcp.path;
224
228
  return IDEInstaller.removeMcpConfig(configPath, "sessionmem");
225
229
  }
226
230
  async call(_method, _request) {
@@ -11,13 +11,16 @@ import { IDEInstaller } from "../ide/installer.js";
11
11
  const ANTIGRAVITY_MCP_CONFIG = [".gemini", "config", "mcp_config.json"];
12
12
  export class AntigravityAdapter extends GenericMCPAdapter {
13
13
  name = "Antigravity";
14
+ installation() {
15
+ return { mcp: { path: join(homedir(), ...ANTIGRAVITY_MCP_CONFIG), command: "sessionmem", args: ["run"] } };
16
+ }
14
17
  // Capabilities inherited from GenericMCPAdapter (tools only).
15
18
  /** Antigravity reads AGENTS.md-style guidance from its global config dir. */
16
19
  guidanceTargets() {
17
20
  return [join(homedir(), ".antigravity", "AGENTS.md")];
18
21
  }
19
22
  async install() {
20
- const configPath = join(homedir(), ...ANTIGRAVITY_MCP_CONFIG);
23
+ const configPath = this.installation().mcp.path;
21
24
  return IDEInstaller.injectMcpConfig(configPath, "sessionmem", "sessionmem", [
22
25
  "run",
23
26
  ]);
@@ -21,15 +21,30 @@ export class ClaudeCodeAdapter extends GenericMCPAdapter {
21
21
  settingsPath() {
22
22
  return join(homedir(), ".claude", "settings.json");
23
23
  }
24
+ installation() {
25
+ const isWindows = process.platform === "win32";
26
+ return {
27
+ mcp: {
28
+ path: join(homedir(), ".claude.json"),
29
+ command: isWindows ? "cmd" : "sessionmem",
30
+ args: isWindows ? ["/c", "sessionmem", "run"] : ["run"],
31
+ },
32
+ hooks: {
33
+ path: this.settingsPath(),
34
+ entries: [
35
+ { event: "SessionStart", command: SESSIONMEM_HOOK_COMMAND },
36
+ { event: "SessionEnd", command: SESSIONMEM_SESSION_END_HOOK_COMMAND },
37
+ { event: "PostToolUse", command: SESSIONMEM_POST_TOOL_HOOK_COMMAND, matcher: SESSIONMEM_POST_TOOL_MATCHER },
38
+ ],
39
+ },
40
+ };
41
+ }
24
42
  async install() {
25
- const configPath = join(homedir(), ".claude.json");
26
43
  // On Windows, the globally-installed `sessionmem` bin is a `.cmd` shim.
27
44
  // Claude Code spawns MCP servers WITHOUT a shell, and `.cmd` shims require
28
45
  // cmd.exe to execute, so a bare `command: "sessionmem"` fails to start.
29
46
  // Route through `cmd /c sessionmem run` so the shim is resolved correctly.
30
- const isWindows = process.platform === "win32";
31
- const command = isWindows ? "cmd" : "sessionmem";
32
- const args = isWindows ? ["/c", "sessionmem", "run"] : ["run"];
47
+ const { path: configPath, command, args } = this.installation().mcp;
33
48
  const mcpOk = await IDEInstaller.injectMcpConfig(configPath, "sessionmem", command, args);
34
49
  // Register a SessionStart hook so prior memories are injected automatically
35
50
  // at the start of EVERY Claude Code session. This is the deterministic
@@ -12,6 +12,9 @@ args = ["run"]
12
12
  `;
13
13
  export class CodexAdapter extends GenericMCPAdapter {
14
14
  name = "Codex";
15
+ installation() {
16
+ return { mcp: { path: join(homedir(), ".codex", "config.toml"), command: "sessionmem", args: ["run"], format: "toml" } };
17
+ }
15
18
  // Capabilities inherited from GenericMCPAdapter (tools only).
16
19
  /** Codex reads AGENTS.md; the global one lives in ~/.codex/AGENTS.md. */
17
20
  guidanceTargets() {
@@ -20,21 +23,19 @@ export class CodexAdapter extends GenericMCPAdapter {
20
23
  async install() {
21
24
  try {
22
25
  const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import("fs");
23
- const configPath = join(homedir(), ".codex", "config.toml");
26
+ const configPath = this.installation().mcp.path;
24
27
  const existing = existsSync(configPath)
25
28
  ? readFileSync(configPath, "utf-8")
26
29
  : "";
27
- // Idempotent: skip if the sessionmem server section already exists.
28
- // Anchor on line start so a commented/substring mention doesn't match.
29
- if (existing.split("\n").some((line) => line.trimStart().startsWith("[mcp_servers.sessionmem]")))
30
- return true;
30
+ // Replace our section so re-install also repairs stale launch commands.
31
+ const withoutServer = existing.replace(/^\s*\[mcp_servers\.sessionmem\][^\S\r\n]*\r?\n[\s\S]*?(?=^\s*\[|$(?![\s\S]))/m, "");
31
32
  const dir = dirname(configPath);
32
33
  if (!existsSync(dir)) {
33
34
  mkdirSync(dir, { recursive: true });
34
35
  }
35
36
  // Append the TOML block, trimming trailing whitespace to keep a single
36
37
  // blank-line separator before our section.
37
- const updated = existing.trimEnd() + "\n" + CODEX_MCP_BLOCK;
38
+ const updated = withoutServer.trimEnd() + "\n" + CODEX_MCP_BLOCK;
38
39
  writeFileSync(configPath, updated, "utf-8");
39
40
  return true;
40
41
  }
@@ -17,13 +17,16 @@ import { IDEInstaller } from "../ide/installer.js";
17
17
  // directory spelling, or wire it through the UI-managed store if no file exists.
18
18
  export class QCoderAdapter extends GenericMCPAdapter {
19
19
  name = "QCoder";
20
+ installation() {
21
+ return { mcp: { path: join(homedir(), ".qcoder", "config.json"), command: "sessionmem", args: ["run"] } };
22
+ }
20
23
  // Capabilities inherited from GenericMCPAdapter (tools only).
21
24
  /** QCoder reads AGENTS.md-style guidance from its global config dir. */
22
25
  guidanceTargets() {
23
26
  return [join(homedir(), ".qcoder", "AGENTS.md")];
24
27
  }
25
28
  async install() {
26
- const configPath = join(homedir(), ".qcoder", "config.json");
29
+ const configPath = this.installation().mcp.path;
27
30
  return IDEInstaller.injectMcpConfig(configPath, "sessionmem", "sessionmem", [
28
31
  "run",
29
32
  ]);
@@ -59,6 +59,9 @@ export class ClineAdapter extends GenericMCPAdapter {
59
59
  // expects to read.
60
60
  return IDEInstaller.injectMcpConfig(this.configPath, "sessionmem", "sessionmem", ["run"], { disabled: false, autoApprove: [] });
61
61
  }
62
+ installation() {
63
+ return { mcp: { path: this.configPath, command: "sessionmem", args: ["run"] } };
64
+ }
62
65
  async uninstall() {
63
66
  return IDEInstaller.removeMcpConfig(this.configPath, "sessionmem");
64
67
  }
@@ -21,6 +21,9 @@ export class CursorAdapter extends GenericMCPAdapter {
21
21
  get configPath() {
22
22
  return join(homedir(), ".cursor", "mcp.json");
23
23
  }
24
+ installation() {
25
+ return { mcp: { path: this.configPath, command: "sessionmem", args: ["run"] } };
26
+ }
24
27
  async install() {
25
28
  return IDEInstaller.injectMcpConfig(this.configPath, "sessionmem", "sessionmem", ["run"]);
26
29
  }
@@ -35,6 +35,15 @@ export const SESSIONMEM_POST_TOOL_HOOK_COMMAND = _isWindows
35
35
  export const SESSIONMEM_POST_TOOL_MATCHER = "Bash|Edit|Write|MultiEdit";
36
36
  /** Default Claude Code hook event for the legacy 2-arg hook helpers. */
37
37
  const DEFAULT_HOOK_EVENT = "SessionStart";
38
+ /** Recognize our bare/Windows hook forms, including stale command arguments. */
39
+ export function isManagedClaudeHook(value, expected) {
40
+ if (typeof value !== "string")
41
+ return false;
42
+ const normalize = (command) => command.replace(/^cmd \/c /i, "");
43
+ const base = normalize(expected);
44
+ const candidate = normalize(value);
45
+ return candidate === base || candidate.startsWith(`${base} `);
46
+ }
38
47
  export class IDEInstaller {
39
48
  static parseJsonc(content) {
40
49
  // Strip single-line comments (//) that appear outside of string literals.
@@ -201,11 +210,14 @@ export class IDEInstaller {
201
210
  const eventEntries = Array.isArray(hooks[eventName])
202
211
  ? hooks[eventName]
203
212
  : [];
204
- // Drop any pre-existing sessionmem entry so re-install stays idempotent and
205
- // a stale command form is replaced rather than duplicated. hookEntryHasCommand
206
- // only inspects entry.hooks, so it dedupes correctly whether or not the entry
207
- // carries a matcher.
208
- const filtered = eventEntries.filter((entry) => !this.hookEntryHasCommand(entry, command));
213
+ // Replace our commands, including stale forms, while preserving user hooks
214
+ // that share an entry. The fresh entry restores the current matcher/type.
215
+ const filtered = eventEntries.flatMap((entry) => {
216
+ if (!entry || !Array.isArray(entry.hooks))
217
+ return [entry];
218
+ const remaining = entry.hooks.filter((hook) => !isManagedClaudeHook(hook?.command, command));
219
+ return remaining.length ? [{ ...entry, hooks: remaining }] : [];
220
+ });
209
221
  filtered.push(matcher !== undefined
210
222
  ? { matcher, hooks: [{ type: "command", command }] }
211
223
  : { hooks: [{ type: "command", command }] });
@@ -20,6 +20,9 @@ export class WindsurfAdapter extends GenericMCPAdapter {
20
20
  get configPath() {
21
21
  return join(homedir(), ".codeium", "windsurf", "mcp_config.json");
22
22
  }
23
+ installation() {
24
+ return { mcp: { path: this.configPath, command: "sessionmem", args: ["run"] } };
25
+ }
23
26
  async install() {
24
27
  return IDEInstaller.injectMcpConfig(this.configPath, "sessionmem", "sessionmem", ["run"]);
25
28
  }
@@ -0,0 +1,51 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, join } from "node:path";
4
+ import { AdapterFactory } from "./factory.js";
5
+ // Installed-host detection is deliberately separate from runtime session-env
6
+ // detection. Add a row here when another host has a verified install probe.
7
+ export const INSTALLED_HOSTS = [
8
+ {
9
+ adapter: "claude-code",
10
+ executables: ["claude"],
11
+ homeExecutables: [".local/bin/claude", ".claude/local/claude"],
12
+ },
13
+ ];
14
+ export function isExecutable(file) {
15
+ try {
16
+ if (!statSync(file).isFile())
17
+ return false;
18
+ accessSync(file, process.platform === "win32" ? constants.F_OK : constants.X_OK);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export function executableCandidates(file) {
26
+ if (process.platform !== "win32")
27
+ return [file];
28
+ const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";");
29
+ return [file, ...extensions.filter(Boolean).map((ext) => file + ext.toLowerCase())];
30
+ }
31
+ export function detectInstalledAdapters(table = INSTALLED_HOSTS) {
32
+ const pathDirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
33
+ return table.filter((host) => {
34
+ const paths = [
35
+ ...host.executables.flatMap((name) => pathDirs.map((dir) => join(dir.replace(/^"|"$/g, ""), name))),
36
+ ...(host.homeExecutables ?? []).map((file) => join(homedir(), file)),
37
+ ];
38
+ return paths.some((file) => executableCandidates(file).some(isExecutable));
39
+ }).map((host) => AdapterFactory.forName(host.adapter));
40
+ }
41
+ /** Explicit flags take precedence over the existing environment override. */
42
+ export function resolveInstallAdapters(options) {
43
+ const explicit = options?.adapter?.trim() || process.env.SESSIONMEM_ADAPTER?.trim();
44
+ if (explicit)
45
+ return { adapters: [AdapterFactory.forName(explicit)], forced: true };
46
+ const adapters = detectInstalledAdapters();
47
+ if (adapters.length === 0) {
48
+ throw new Error("No supported agent host found. Install Claude Code or use sessionmem install --adapter <name>.");
49
+ }
50
+ return { adapters, forced: false };
51
+ }
@@ -23,7 +23,7 @@ function cosineSimilarity(a, b) {
23
23
  function resolveThreshold(raw) {
24
24
  if (raw === undefined)
25
25
  return SIMILARITY_THRESHOLD;
26
- const parsed = Number.parseFloat(raw);
26
+ const parsed = Number(raw.trim());
27
27
  if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) {
28
28
  console.error(`Invalid --threshold "${raw}": expected a number greater than 0 and at most 1.`);
29
29
  process.exit(1);
@@ -0,0 +1,146 @@
1
+ import BetterSqlite3 from "better-sqlite3";
2
+ import { randomUUID } from "node:crypto";
3
+ import { createRequire } from "node:module";
4
+ import { resolveInstallAdapters } from "../../adapters/installedHosts.js";
5
+ import { checkHooks, readMcpLaunch, probeMcpWiring } from "../../adapters/diagnostics.js";
6
+ import { ADAPTER_NAMES, AdapterFactory } from "../../adapters/factory.js";
7
+ import { createMemoryCoreService } from "../../core/api/memoryCoreService.js";
8
+ import { inspectMigrations } from "../../core/schema/runMigrations.js";
9
+ import { resolveDbPath } from "../context.js";
10
+ const VERSION = createRequire(import.meta.url)("../../../package.json").version;
11
+ /** Exercise real storage, embedding, ranking and injection without retaining data. */
12
+ export async function probeRecall(db) {
13
+ const id = `sessionmem-doctor-${randomUUID()}`;
14
+ const content = "Sessionmem doctor canary checks local memory recall.";
15
+ const service = createMemoryCoreService({ db });
16
+ // A separate project prevents both ranking collisions and access-count changes
17
+ // to user memories. Rollback also removes FTS entries and feedback on failure.
18
+ db.exec("BEGIN IMMEDIATE");
19
+ try {
20
+ const stored = await service.call("storeMemory", {
21
+ projectId: id, sessionId: id, memoryId: id, sourceAdapter: "doctor",
22
+ kind: "fact", content, importance: 5,
23
+ });
24
+ if (!stored.ok)
25
+ throw new Error(`Canary write failed: ${stored.error.message}`);
26
+ const recalled = await service.call("retrieveMemories", {
27
+ projectId: id, query: content, limit: 5, mode: "auto", depth: "default",
28
+ });
29
+ if (!recalled.ok)
30
+ throw new Error(`Canary recall failed: ${recalled.error.message}`);
31
+ if (!recalled.memories.some((memory) => memory.id === id && memory.content === content)) {
32
+ throw new Error("Recall did not return the canary memory");
33
+ }
34
+ }
35
+ finally {
36
+ db.exec("ROLLBACK");
37
+ }
38
+ }
39
+ export async function doctorCommand(options, overrides = {}) {
40
+ const results = [];
41
+ const report = (check, ok, detail, fix) => {
42
+ results.push({ check, ok, detail, ...(fix ? { fix } : {}) });
43
+ console.log(`${ok ? "PASS" : "FAIL"} ${check}: ${detail}`);
44
+ if (fix)
45
+ console.log(` Fix: ${fix}`);
46
+ };
47
+ const message = (err) => err instanceof Error ? err.message : String(err);
48
+ let adapters = [];
49
+ try {
50
+ adapters = resolveInstallAdapters(options).adapters;
51
+ }
52
+ catch (err) {
53
+ report("Agent hosts", false, message(err), "Install a supported host or select one with sessionmem doctor --adapter <name>");
54
+ }
55
+ const dbPath = resolveDbPath(overrides.dbPath);
56
+ let db;
57
+ let schemaOk = false;
58
+ try {
59
+ try {
60
+ if (dbPath === ":memory:")
61
+ throw new Error("No persistent memory store selected (:memory:)");
62
+ // fileMustExist prevents doctor from hiding a missing store; migrations
63
+ // are inspected separately and are never applied during diagnosis.
64
+ db = new BetterSqlite3(dbPath, { fileMustExist: true, timeout: 5000 });
65
+ const status = db.pragma("quick_check", { simple: true });
66
+ if (status !== "ok")
67
+ throw new Error(`SQLite quick_check: ${String(status)}`);
68
+ db.pragma("foreign_keys = ON");
69
+ report("Memory store", true, `Exists and opens (${dbPath})`);
70
+ }
71
+ catch (err) {
72
+ db?.close();
73
+ db = undefined;
74
+ report("Memory store", false, message(err), "Check the selected database path and directory permissions; initialize a missing store with sessionmem ping");
75
+ }
76
+ if (db) {
77
+ try {
78
+ const schema = inspectMigrations(db, overrides.migrationsDir);
79
+ if (schema.unknown.length) {
80
+ report("Schema", false, `Unrecognized migrations: ${schema.unknown.join(", ")}`, "Use the sessionmem version that created this database; do not downgrade its schema");
81
+ }
82
+ else if (schema.pending.length) {
83
+ report("Schema", false, `Pending migrations: ${schema.pending.join(", ")}`, "sessionmem ping");
84
+ }
85
+ else {
86
+ schemaOk = true;
87
+ report("Schema", true, "All bundled migrations are applied");
88
+ }
89
+ }
90
+ catch (err) {
91
+ report("Schema", false, message(err), "Check the database and bundled schema migrations; sessionmem ping applies pending migrations");
92
+ }
93
+ }
94
+ else {
95
+ report("Schema", false, "Cannot inspect an unavailable store", "sessionmem ping");
96
+ }
97
+ for (const adapter of adapters) {
98
+ const name = ADAPTER_NAMES.find((name) => AdapterFactory.forName(name).name === adapter.name);
99
+ const installFix = name ? `sessionmem install --adapter ${name}` : "sessionmem install";
100
+ const installation = adapter.installation?.();
101
+ if (!installation) {
102
+ report(`${adapter.name} hooks`, false, "Adapter has no installation diagnostics", installFix);
103
+ report(`${adapter.name} adapter wiring`, false, "Adapter has no installation diagnostics", installFix);
104
+ continue;
105
+ }
106
+ try {
107
+ report(`${adapter.name} hooks`, true, `${checkHooks(installation)}; sessionmem ${VERSION}`);
108
+ }
109
+ catch (err) {
110
+ report(`${adapter.name} hooks`, false, message(err), `Enable hooks in the host settings if disabled, then run ${installFix}`);
111
+ }
112
+ try {
113
+ const launch = readMcpLaunch(installation);
114
+ if (!schemaOk)
115
+ throw new Error("MCP launch probe blocked until the store and schema checks pass");
116
+ await probeMcpWiring(launch, dbPath, VERSION);
117
+ report(`${adapter.name} adapter wiring`, true, "Configured MCP command starts, matches this version, and answers stats");
118
+ }
119
+ catch (err) {
120
+ report(`${adapter.name} adapter wiring`, false, message(err), `Ensure sessionmem is on PATH, then run ${installFix}`);
121
+ }
122
+ }
123
+ if (!adapters.length) {
124
+ report("Hook configuration", false, "No host selected", "sessionmem doctor --adapter <name>");
125
+ report("Adapter wiring", false, "No host selected", "sessionmem doctor --adapter <name>");
126
+ }
127
+ if (db && schemaOk) {
128
+ try {
129
+ await probeRecall(db);
130
+ report("Recall canary", true, "Write and recall succeeded; canary rolled back");
131
+ }
132
+ catch (err) {
133
+ report("Recall canary", false, message(err), "Check database write permissions and errors; run sessionmem doctor again after repair");
134
+ }
135
+ }
136
+ else {
137
+ report("Recall canary", false, "Blocked until the store and schema checks pass", "sessionmem ping");
138
+ }
139
+ }
140
+ finally {
141
+ db?.close();
142
+ }
143
+ if (results.some((result) => !result.ok))
144
+ process.exitCode = 1;
145
+ return results;
146
+ }
@@ -0,0 +1,27 @@
1
+ import { listProjectKnowledge } from "../../core/storage/knowledgeRepo.js";
2
+ import { createCliContext } from "../context.js";
3
+ export function entitiesCommand(options = {}, ctx) {
4
+ const context = ctx ?? createCliContext();
5
+ try {
6
+ const projectId = options.project ?? context.projectId;
7
+ const knowledge = listProjectKnowledge(context.db, projectId);
8
+ if (options.json) {
9
+ console.log(JSON.stringify({ projectId, ...knowledge }, null, 2));
10
+ return;
11
+ }
12
+ console.log(`Entities for ${projectId}:`);
13
+ for (const entity of knowledge.entities) {
14
+ console.log(` [${entity.kind}] ${entity.name} (memories: ${entity.memoryIds.join(", ")})`);
15
+ }
16
+ console.log("Relations:");
17
+ for (const edge of knowledge.relations) {
18
+ console.log(` [${edge.sourceKind}] ${edge.sourceName} --${edge.relation}--> [${edge.targetKind}] ${edge.targetName} (memories: ${edge.memoryIds.join(", ")})`);
19
+ }
20
+ if (!knowledge.entities.length)
21
+ console.log(" No entities captured.");
22
+ }
23
+ finally {
24
+ if (!ctx)
25
+ context.db.close();
26
+ }
27
+ }
@@ -0,0 +1,23 @@
1
+ import { listSupersessions } from "../../core/storage/supersessionRepo.js";
2
+ import { createCliContext } from "../context.js";
3
+ export function historyCommand(options = {}, ctx) {
4
+ const context = ctx ?? createCliContext();
5
+ try {
6
+ const projectId = options.project ?? context.projectId;
7
+ const links = listSupersessions(context.db, projectId);
8
+ if (options.json) {
9
+ console.log(JSON.stringify({ projectId, links }, null, 2));
10
+ return;
11
+ }
12
+ console.log(`Supersession links for ${projectId}:`);
13
+ for (const link of links) {
14
+ console.log(` ${link.supersedes} -> ${link.supersededBy} at ${link.supersededAt} (${link.entity} ${link.relation}: ${link.oldValue} -> ${link.newValue}; ${link.reason}; ${link.fullySuperseded ? "superseded" : "partially superseded"})`);
15
+ }
16
+ if (!links.length)
17
+ console.log(" No supersessions captured.");
18
+ }
19
+ finally {
20
+ if (!ctx)
21
+ context.db.close();
22
+ }
23
+ }