sessionmem 1.0.5 → 1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +372 -365
  3. package/dist/adapters/capabilities/fallbackTools.js +33 -18
  4. package/dist/adapters/claudeMdInjector.js +164 -0
  5. package/dist/adapters/factory.js +68 -9
  6. package/dist/adapters/generic.js +221 -15
  7. package/dist/adapters/global/antigravity.js +14 -7
  8. package/dist/adapters/global/claudeCode.js +46 -10
  9. package/dist/adapters/global/codex.js +73 -13
  10. package/dist/adapters/global/qcoder.js +18 -5
  11. package/dist/adapters/ide/cline.js +54 -9
  12. package/dist/adapters/ide/cursor.js +15 -13
  13. package/dist/adapters/ide/installer.js +201 -8
  14. package/dist/adapters/ide/windsurf.js +14 -13
  15. package/dist/adapters/tools/ping.js +4 -1
  16. package/dist/cli/commands/config.js +10 -1
  17. package/dist/cli/commands/import.js +6 -1
  18. package/dist/cli/commands/install.js +63 -5
  19. package/dist/cli/commands/ping.js +42 -8
  20. package/dist/cli/commands/reEmbed.js +48 -0
  21. package/dist/cli/commands/run.js +18 -2
  22. package/dist/cli/commands/savings.js +91 -0
  23. package/dist/cli/commands/sessionEnd.js +124 -0
  24. package/dist/cli/commands/sessionStart.js +52 -0
  25. package/dist/cli/commands/sync.js +39 -9
  26. package/dist/cli/commands/uninstall.js +37 -1
  27. package/dist/cli/context.js +14 -18
  28. package/dist/cli/index.js +30 -4
  29. package/dist/cli/output.js +11 -3
  30. package/dist/cli/projectId.js +69 -0
  31. package/dist/core/api/contracts.js +182 -45
  32. package/dist/core/api/errors.js +4 -7
  33. package/dist/core/api/memoryCoreService.js +409 -240
  34. package/dist/core/api/sessionLifecycleService.js +20 -2
  35. package/dist/core/config/policyConfig.js +53 -6
  36. package/dist/core/injection/formatStartupInjection.js +55 -10
  37. package/dist/core/injection/tokenBudget.js +8 -0
  38. package/dist/core/retrieve/importance.js +4 -3
  39. package/dist/core/retrieve/recencyBands.js +6 -10
  40. package/dist/core/retrieve/retrieveMemories.js +19 -4
  41. package/dist/core/retrieve/score.js +11 -1
  42. package/dist/core/schema/migrations/005_team_provenance.sql +14 -9
  43. package/dist/core/schema/migrations/006_access_pattern_boosting.sql +10 -0
  44. package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
  45. package/dist/core/schema/migrations/008_fts5_search.sql +37 -0
  46. package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
  47. package/dist/core/schema/runMigrations.js +64 -2
  48. package/dist/core/storage/db.js +6 -0
  49. package/dist/core/storage/memoryFeedbackRepo.js +14 -4
  50. package/dist/core/storage/memoryRepo.js +292 -121
  51. package/dist/core/storage/memorySearchRepo.js +125 -13
  52. package/dist/core/storage/sessionEventsRepo.js +33 -10
  53. package/dist/core/storage/summarizationFailuresRepo.js +36 -26
  54. package/dist/core/storage/tokenSavingsRepo.js +20 -0
  55. package/dist/core/summarize/cloudSummarizer.js +34 -5
  56. package/dist/core/summarize/localSummarizer.js +1 -10
  57. package/dist/core/summarize/redaction.js +45 -8
  58. package/package.json +50 -48
@@ -20,6 +20,13 @@ function coerceRetentionDays(raw) {
20
20
  }
21
21
  return n;
22
22
  }
23
+ function coerceInjectionCap(raw) {
24
+ const n = coerceInt(raw);
25
+ if (n < 100 || n > 10000) {
26
+ throw new Error(`injectionCap must be an integer between 100 and 10000, got "${raw}"`);
27
+ }
28
+ return n;
29
+ }
23
30
  function coerceBool(raw) {
24
31
  const v = raw.trim().toLowerCase();
25
32
  if (v === "true")
@@ -34,6 +41,7 @@ const CONFIG_KEYS = {
34
41
  "retention.days": { field: "retentionDays", coerce: coerceRetentionDays },
35
42
  retentionDays: { field: "retentionDays", coerce: coerceRetentionDays },
36
43
  redactionEnabled: { field: "redactionEnabled", coerce: coerceBool },
44
+ injectionCap: { field: "injectionCap", coerce: coerceInjectionCap },
37
45
  };
38
46
  function resolvePath(options) {
39
47
  return options?.configPath ?? configFilePath();
@@ -50,7 +58,8 @@ export function configGetCommand(key, options) {
50
58
  return;
51
59
  }
52
60
  const config = readPolicyConfig(resolvePath(options));
53
- console.log(String(config[def.field]));
61
+ const val = config[def.field];
62
+ console.log(val === undefined ? "(not set)" : String(val));
54
63
  }
55
64
  /**
56
65
  * `sessionmem config set <key> <value>` — coerce and persist to config.json.
@@ -73,7 +73,9 @@ export async function importCommand(pathArg, options, ctx) {
73
73
  invalidCount += 1;
74
74
  continue;
75
75
  }
76
- validMemories.push(mapped[i]);
76
+ // Use the parsed/transformed record so `kind` is narrowed to the
77
+ // canonical enum (memoryKindSchema maps legacy values like `architecture`).
78
+ validMemories.push(check.data);
77
79
  }
78
80
  if (validMemories.length === 0) {
79
81
  if (invalidCount > 0) {
@@ -100,6 +102,9 @@ export async function importCommand(pathArg, options, ctx) {
100
102
  let suffix = result.skippedCrossProject > 0
101
103
  ? ` (${result.skippedCrossProject} skipped: id belongs to another project)`
102
104
  : "";
105
+ if (result.skippedExisting > 0) {
106
+ suffix += ` (${result.skippedExisting} skipped: id already exists in this project)`;
107
+ }
103
108
  if (invalidCount > 0) {
104
109
  suffix += ` (${invalidCount} invalid record(s) skipped)`;
105
110
  }
@@ -1,6 +1,26 @@
1
1
  import { existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
2
4
  import { AdapterFactory } from "../../adapters/factory.js";
5
+ import { injectGuidanceBlock } from "../../adapters/claudeMdInjector.js";
3
6
  import { createCliContext } from "../context.js";
7
+ /**
8
+ * Resolve the adapter to install into: an explicit `--adapter` flag wins, then
9
+ * the SESSIONMEM_ADAPTER env override, else auto-detection from the host env.
10
+ * Throws (via AdapterFactory.forName) on an unknown explicit name so the CLI
11
+ * surfaces a clear error rather than silently installing the generic adapter.
12
+ */
13
+ function resolveAdapter(options) {
14
+ const explicit = options?.adapter && options.adapter.trim() !== ""
15
+ ? options.adapter.trim()
16
+ : process.env.SESSIONMEM_ADAPTER && process.env.SESSIONMEM_ADAPTER.trim() !== ""
17
+ ? process.env.SESSIONMEM_ADAPTER.trim()
18
+ : undefined;
19
+ if (explicit) {
20
+ return { adapter: AdapterFactory.forName(explicit), forced: true };
21
+ }
22
+ return { adapter: AdapterFactory.detectAdapter(), forced: false };
23
+ }
4
24
  import { configFilePath, writePolicyConfig, DEFAULT_POLICY_CONFIG, } from "../../core/config/policyConfig.js";
5
25
  export const MANUAL_CONFIG_BLOCK = JSON.stringify({
6
26
  mcpServers: {
@@ -14,7 +34,18 @@ export function printManualFallback(adapterName) {
14
34
  console.error(`Auto-config for ${adapterName} failed. Add this block to your MCP config manually:`);
15
35
  console.log(MANUAL_CONFIG_BLOCK);
16
36
  }
17
- export async function installCommand(_options, contextOverrides) {
37
+ export async function installCommand(options, contextOverrides) {
38
+ // Resolve the target adapter up front so an invalid `--adapter` fails before
39
+ // any filesystem work (DB init, config write).
40
+ let adapter;
41
+ let forced;
42
+ try {
43
+ ({ adapter, forced } = resolveAdapter(options));
44
+ }
45
+ catch (err) {
46
+ console.error(err instanceof Error ? err.message : String(err));
47
+ process.exit(1);
48
+ }
18
49
  // Step 1: DB init — run migrations and confirm DB init
19
50
  let dbPath;
20
51
  try {
@@ -38,8 +69,12 @@ export async function installCommand(_options, contextOverrides) {
38
69
  writePolicyConfig(configPath, { ...DEFAULT_POLICY_CONFIG });
39
70
  console.log(`✓ config.json initialized (${configPath})`);
40
71
  }
41
- // Step 2: Adapter config — detect adapter and install
42
- const adapter = AdapterFactory.detectAdapter();
72
+ // Step 2: Adapter config — install into the resolved adapter. Print which
73
+ // adapter was selected and how, so the user can see whether auto-detection
74
+ // picked the host they expected (and re-run with `--adapter` if not).
75
+ console.log(forced
76
+ ? `→ Installing for adapter: ${adapter.name} (forced)`
77
+ : `→ Detected host: ${adapter.name} (override with --adapter <name>)`);
43
78
  if (!adapter.install) {
44
79
  console.error(`✗ ${adapter.name} config update failed`);
45
80
  printManualFallback(adapter.name);
@@ -52,6 +87,29 @@ export async function installCommand(_options, contextOverrides) {
52
87
  process.exit(1);
53
88
  }
54
89
  console.log(`✓ ${adapter.name} config updated`);
55
- // Step 3: Full success checklist
56
- console.log("✓ sessionmem ready");
90
+ // Step 3: Guidance injection — non-fatal. Inject the sessionmem instruction
91
+ // block into the file(s) the detected host actually reads at session start so
92
+ // the agent automatically knows the MCP exists and how to use it. Adapters
93
+ // declare their own target(s); when none are declared (e.g. a minimal mock or
94
+ // an unknown host) fall back to the global Claude Code memory file.
95
+ const guidanceTargets = adapter.guidanceTargets?.() ?? [join(homedir(), ".claude", "CLAUDE.md")];
96
+ for (const target of guidanceTargets) {
97
+ try {
98
+ const injected = injectGuidanceBlock(target);
99
+ if (injected) {
100
+ console.log(`✓ Agent guidance injected (${target})`);
101
+ }
102
+ else {
103
+ console.error(`✗ Agent guidance injection failed (non-fatal): ${target}`);
104
+ }
105
+ }
106
+ catch {
107
+ console.error(`✗ Agent guidance injection failed (non-fatal): ${target}`);
108
+ }
109
+ }
110
+ // Step 4: Full success checklist
111
+ if (adapter.name === "Claude Code") {
112
+ console.log("✓ Auto-injection hook installed (~/.claude/settings.json) — prior memories load automatically at the start of every Claude Code session");
113
+ }
114
+ console.log("✓ sessionmem ready — restart your editor/agent so it picks up the MCP server");
57
115
  }
@@ -1,12 +1,46 @@
1
1
  import { pingTool } from "../../adapters/tools/ping.js";
2
- export async function pingCommand() {
3
- const result = await pingTool.execute();
4
- console.log(`status: ${result.status}`);
5
- console.log(`version: ${result.version}`);
6
- console.log(`message: ${result.message}`);
7
- if (result.status !== "ok") {
8
- console.error(`sessionmem ping failed: ${result.message}`);
2
+ import { createCliContext } from "../context.js";
3
+ /**
4
+ * `sessionmem ping` — report the local version AND verify the memory store is
5
+ * actually reachable.
6
+ *
7
+ * The previous implementation only echoed the package version and always
8
+ * printed "ok", so a broken/locked database still reported healthy. This opens
9
+ * the DB (running migrations) and executes a trivial query; a failure is
10
+ * surfaced as a non-ok status and a non-zero exit code.
11
+ */
12
+ export async function pingCommand(ctx) {
13
+ const versionResult = await pingTool.execute();
14
+ console.log(`version: ${versionResult.version}`);
15
+ let context;
16
+ try {
17
+ context = ctx ?? createCliContext();
18
+ }
19
+ catch (err) {
20
+ console.log("status: error");
21
+ console.log(`message: database could not be opened: ${err instanceof Error ? err.message : String(err)}`);
22
+ console.error("sessionmem ping failed: database unreachable");
23
+ process.exit(1);
24
+ }
25
+ try {
26
+ // Trivial round-trip through the service layer proves migrations ran and
27
+ // the DB answers queries for the current project.
28
+ const result = await context.service.call("stats", {
29
+ projectId: context.projectId,
30
+ });
31
+ if (!result.ok) {
32
+ console.log("status: error");
33
+ console.log(`message: database query failed: ${result.error.message}`);
34
+ console.error("sessionmem ping failed: database query failed");
35
+ process.exit(1);
36
+ }
37
+ }
38
+ catch (err) {
39
+ console.log("status: error");
40
+ console.log(`message: database query failed: ${err instanceof Error ? err.message : String(err)}`);
41
+ console.error("sessionmem ping failed: database query failed");
9
42
  process.exit(1);
10
43
  }
11
- // exit 0 on success (implicit)
44
+ console.log("status: ok");
45
+ console.log("message: sessionmem is operational (database reachable).");
12
46
  }
@@ -0,0 +1,48 @@
1
+ import { createCliContext } from "../context.js";
2
+ import { deterministicEmbed } from "../../core/embed/deterministicEmbed.js";
3
+ import { EMBEDDING_VERSION } from "../../core/embed/embeddingVersion.js";
4
+ const DEFAULT_EMBEDDING_DIMENSION = 32;
5
+ /**
6
+ * `sessionmem re-embed`
7
+ *
8
+ * Bulk-update embeddings for all memories whose embedding_version does not
9
+ * match the current EMBEDDING_VERSION. Recomputes each embedding with
10
+ * deterministicEmbed and writes the new vector + version back to the row.
11
+ */
12
+ export async function reEmbedCommand(ctx) {
13
+ const context = ctx ?? createCliContext();
14
+ const { db, projectId } = context;
15
+ const stale = db
16
+ .prepare(`
17
+ SELECT id, content, embedding_dim
18
+ FROM memories
19
+ WHERE project_id = ?
20
+ AND (embedding_version IS NULL OR embedding_version != ?)
21
+ `)
22
+ .all(projectId, EMBEDDING_VERSION);
23
+ const total = stale.length;
24
+ if (total === 0) {
25
+ console.log("All embeddings are up to date.");
26
+ return;
27
+ }
28
+ console.log(`Found ${total} memories with stale embeddings. Re-embedding...`);
29
+ const updateStmt = db.prepare(`
30
+ UPDATE memories
31
+ SET embedding = ?, embedding_dim = ?, embedding_version = ?
32
+ WHERE id = ?
33
+ `);
34
+ let count = 0;
35
+ const runAll = db.transaction(() => {
36
+ for (const row of stale) {
37
+ const dim = row.embedding_dim ?? DEFAULT_EMBEDDING_DIMENSION;
38
+ const result = deterministicEmbed(row.content, dim);
39
+ updateStmt.run(JSON.stringify(result.vector), result.dimension, EMBEDDING_VERSION, row.id);
40
+ count += 1;
41
+ if (count % 100 === 0 || count === total) {
42
+ console.log(` ${count}/${total}`);
43
+ }
44
+ }
45
+ });
46
+ runAll();
47
+ console.log(`Re-embedded ${count} memories to version ${EMBEDDING_VERSION}.`);
48
+ }
@@ -2,12 +2,24 @@ import { AdapterFactory } from "../../adapters/factory.js";
2
2
  import { mkdirSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
+ import { deriveProjectId } from "../projectId.js";
6
+ import { expandTilde } from "../context.js";
5
7
  export async function runMcpServer() {
6
8
  const adapter = AdapterFactory.detectAdapter();
7
- // Setup rudimentary log for debugging manual configs
9
+ // Startup diagnostics: written to ~/.sessionmem/logs/mcp.log
8
10
  const logDir = join(homedir(), ".sessionmem", "logs");
9
11
  const logPath = join(logDir, "mcp.log");
10
- const logMessage = `[${new Date().toISOString()}] Started sessionmem via ${adapter.name}\n`;
12
+ // Derive db path for diagnostics (mirrors context.ts defaultDbPath)
13
+ const envDbPath = process.env.SESSIONMEM_DB_PATH;
14
+ const dbPath = envDbPath && envDbPath.trim() !== ""
15
+ ? expandTilde(envDbPath)
16
+ : join(homedir(), ".sessionmem", "memories.db");
17
+ // Derive project ID for diagnostics. Shares the exact derivation the server
18
+ // uses (createCliContext → deriveProjectId) so the logged id never diverges
19
+ // from the bucket the server actually serves.
20
+ const projectId = deriveProjectId();
21
+ const adapterName = adapter.name;
22
+ const logMessage = `[${new Date().toISOString()}] Started sessionmem | adapter=${adapterName} db=${dbPath} project=${projectId}\n`;
11
23
  try {
12
24
  mkdirSync(logDir, { recursive: true });
13
25
  writeFileSync(logPath, logMessage, { flag: "a" });
@@ -15,6 +27,10 @@ export async function runMcpServer() {
15
27
  catch {
16
28
  // best-effort logging; ignore failures
17
29
  }
30
+ // Debug output to stderr (never stdout — that's the MCP protocol channel)
31
+ if (process.env.SESSIONMEM_DEBUG === "1") {
32
+ process.stderr.write(`[sessionmem] db=${dbPath} project=${projectId} adapter=${adapterName}\n`);
33
+ }
18
34
  // Start the server
19
35
  if (adapter.startMcpServer) {
20
36
  await adapter.startMcpServer();
@@ -0,0 +1,91 @@
1
+ import { createCliContext } from "../context.js";
2
+ import { countTokens } from "../../core/injection/tokenBudget.js";
3
+ import { listMemoryContentsByProject } from "../../core/storage/memoryRepo.js";
4
+ import { countDistinctSessions, listEventPayloads, } from "../../core/storage/tokenSavingsRepo.js";
5
+ import { readPolicyConfig, configFilePath, } from "../../core/config/policyConfig.js";
6
+ /** Default injection token cap, mirroring formatStartupInjection.ts. */
7
+ const DEFAULT_INJECTION_CAP = 450;
8
+ export function savingsCommand(ctx, options) {
9
+ const context = ctx ?? createCliContext();
10
+ const { db, projectId } = context;
11
+ // --- gather raw numbers ---
12
+ const memoryTokens = listMemoryContentsByProject(db, projectId).reduce((sum, content) => sum + countTokens(content), 0);
13
+ const rawEventTokens = listEventPayloads(db, projectId).reduce((sum, p) => sum + countTokens(p), 0);
14
+ const sessions = countDistinctSessions(db, projectId);
15
+ // Read the injection cap from policy config if it exists; fall back to 450.
16
+ const policyConfig = readPolicyConfig(options?.configPath ?? configFilePath());
17
+ const injectionCap = policyConfig.injectionCap ?? DEFAULT_INJECTION_CAP;
18
+ // --- calculations ---
19
+ const tokensSaved = rawEventTokens - memoryTokens;
20
+ // Clamp for display. When memories exist but no session events were ingested
21
+ // (rawEventTokens === 0), the raw subtraction goes negative, which is
22
+ // misleading. Never present a negative "tokens saved" to the user.
23
+ const displayedTokensSaved = Math.max(0, tokensSaved);
24
+ const savingsPct = rawEventTokens > 0 ? (tokensSaved / rawEventTokens) * 100 : 0;
25
+ const estimatedReexplainTokens = memoryTokens * 3;
26
+ const injectionSavings = estimatedReexplainTokens - sessions * injectionCap;
27
+ const overallSaved = tokensSaved + Math.max(0, injectionSavings);
28
+ const displayedOverallSaved = Math.max(0, overallSaved);
29
+ const overallPct = rawEventTokens + estimatedReexplainTokens > 0
30
+ ? (overallSaved / (rawEventTokens + estimatedReexplainTokens)) * 100
31
+ : 0;
32
+ const displayedOverallPct = displayedOverallSaved > 0 ? overallPct : 0;
33
+ // per-session injection overhead (fixed cap, not a measured average)
34
+ const avgInjectionCost = sessions > 0 ? injectionCap : 0;
35
+ // --- JSON output ---
36
+ if (options?.json) {
37
+ const payload = {
38
+ memoryTokens,
39
+ rawEventTokens,
40
+ tokensSaved: displayedTokensSaved,
41
+ savingsPct: Math.round(savingsPct * 10) / 10,
42
+ sessions,
43
+ injectionCap,
44
+ estimatedReexplainTokens,
45
+ injectionSavings,
46
+ overallSaved: displayedOverallSaved,
47
+ overallPct: Math.round(displayedOverallPct * 10) / 10,
48
+ };
49
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
50
+ return;
51
+ }
52
+ // --- empty state ---
53
+ if (rawEventTokens === 0 && memoryTokens === 0) {
54
+ process.stdout.write("No session data yet. Token savings will appear after your first session.\n");
55
+ return;
56
+ }
57
+ // --- formatted report ---
58
+ const fmt = (n) => n.toLocaleString("en-US");
59
+ // When no session events have been ingested, the storage-compression numbers
60
+ // are meaningless (and "tokens saved" would be negative). Show a helpful note
61
+ // instead of misleading figures.
62
+ const storageLines = rawEventTokens === 0
63
+ ? [
64
+ "Storage compression:",
65
+ " No session data ingested yet.",
66
+ " Tip: call ingestSessionEvents during sessions to track token usage.",
67
+ ]
68
+ : [
69
+ "Storage compression:",
70
+ ` Raw session tokens: ${fmt(rawEventTokens).padStart(10)}`,
71
+ ` Memory tokens: ${fmt(memoryTokens).padStart(10)}`,
72
+ ` Tokens saved: ${fmt(displayedTokensSaved).padStart(10)} (${(Math.round(savingsPct * 10) / 10).toFixed(1)}%)`,
73
+ ];
74
+ const lines = [
75
+ "sessionmem token savings",
76
+ "",
77
+ ...storageLines,
78
+ "",
79
+ "Session injection:",
80
+ ` Total sessions: ${fmt(sessions).padStart(10)}`,
81
+ ` Avg injection cost: ${fmt(avgInjectionCost).padStart(10)} tokens/session`,
82
+ ` Est. re-explain cost:${fmt(estimatedReexplainTokens).padStart(10)} tokens (without sessionmem)`,
83
+ ` Injection savings: ${fmt(Math.max(0, injectionSavings)).padStart(10)} tokens across ${fmt(sessions)} sessions`,
84
+ "",
85
+ "Overall:",
86
+ ` Total tokens saved: ${fmt(displayedOverallSaved).padStart(10)}`,
87
+ ` Efficiency: ${(Math.round(displayedOverallPct * 10) / 10).toFixed(1)}%`,
88
+ "",
89
+ ];
90
+ process.stdout.write(lines.join("\n"));
91
+ }
@@ -0,0 +1,124 @@
1
+ import { createCliContext } from "../context.js";
2
+ import { handleSessionEndConfigSchema } from "../../core/api/contracts.js";
3
+ /**
4
+ * Read the Claude Code hook JSON payload from stdin, if any. The `SessionEnd`
5
+ * hook pipes a JSON object that includes `session_id`, `cwd`, and `reason`. When
6
+ * the command is run interactively (TTY) or no payload arrives, returns `{}`.
7
+ *
8
+ * A short timeout guards against a non-TTY stdin that never reaches EOF so the
9
+ * hook can never hang and block the session from ending.
10
+ */
11
+ async function readHookPayload() {
12
+ if (process.stdin.isTTY) {
13
+ return {};
14
+ }
15
+ const raw = await new Promise((resolve) => {
16
+ let data = "";
17
+ let settled = false;
18
+ const finish = () => {
19
+ if (settled)
20
+ return;
21
+ settled = true;
22
+ resolve(data);
23
+ };
24
+ const timer = setTimeout(() => {
25
+ // The non-TTY stdin never reached EOF. Detach our listeners and pause the
26
+ // stream so the dangling read can't keep the event loop alive (which would
27
+ // hang the hook), then resolve with whatever was buffered.
28
+ process.stdin.removeAllListeners();
29
+ process.stdin.pause();
30
+ finish();
31
+ }, 500);
32
+ timer.unref?.();
33
+ process.stdin.setEncoding("utf8");
34
+ process.stdin.on("data", (chunk) => {
35
+ data += chunk;
36
+ });
37
+ process.stdin.on("end", () => {
38
+ clearTimeout(timer);
39
+ finish();
40
+ });
41
+ process.stdin.on("error", () => {
42
+ clearTimeout(timer);
43
+ finish();
44
+ });
45
+ });
46
+ if (raw.trim() === "") {
47
+ return {};
48
+ }
49
+ try {
50
+ const parsed = JSON.parse(raw);
51
+ return parsed && typeof parsed === "object"
52
+ ? parsed
53
+ : {};
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ }
59
+ /**
60
+ * Resolve the session id for the session-end pipeline. Prefers the hook
61
+ * payload's `session_id`, then the Claude Code `CLAUDE_CODE_SESSION_ID` env var
62
+ * (set inside a Claude Code session), and finally a stable per-day fallback so
63
+ * the retention prune still runs even with no session context.
64
+ */
65
+ function resolveSessionId(payload) {
66
+ const fromPayload = payload.session_id;
67
+ if (typeof fromPayload === "string" && fromPayload.trim() !== "") {
68
+ return fromPayload.trim();
69
+ }
70
+ const fromEnv = process.env.CLAUDE_CODE_SESSION_ID;
71
+ if (fromEnv && fromEnv.trim() !== "") {
72
+ return fromEnv.trim();
73
+ }
74
+ return `session-end-${new Date().toISOString().slice(0, 10)}`;
75
+ }
76
+ /**
77
+ * `sessionmem session-end` — the deterministic write-side counterpart to
78
+ * `session-start`. Installed as a Claude Code `SessionEnd` hook so the
79
+ * session-end pipeline runs automatically once per session:
80
+ * 1. Auto-summarize any ingested session events into a durable memory.
81
+ * 2. Run a light retention prune of memories older than the retention window.
82
+ *
83
+ * Then prints a one-line human summary of what happened. Every error is
84
+ * swallowed so a session can never be blocked from ending.
85
+ */
86
+ export async function sessionEndCommand(ctx) {
87
+ try {
88
+ const payload = ctx ? {} : await readHookPayload();
89
+ const context = ctx ?? createCliContext();
90
+ const sessionId = resolveSessionId(payload);
91
+ const result = await context.service.call("handleSessionEnd", {
92
+ projectId: context.projectId,
93
+ sessionId,
94
+ sourceAdapter: "sessionmem-cli",
95
+ // Resolve the default config (autoSummarize on, threshold 3 events, local
96
+ // summarizer; cloud summarization stays opt-in) so the call matches the
97
+ // request type without relying on the schema default at the call site.
98
+ config: handleSessionEndConfigSchema.parse({}),
99
+ });
100
+ if (!result.ok) {
101
+ // Never block session end — report quietly to stderr only.
102
+ process.stderr.write(`[sessionmem] session-end: ${result.error.message}\n`);
103
+ return;
104
+ }
105
+ switch (result.status) {
106
+ case "stored":
107
+ console.log(`sessionmem: session summary stored (${result.usedMode}); retention prune applied.`);
108
+ break;
109
+ case "skipped_threshold":
110
+ console.log("sessionmem: not enough session events to summarize; retention prune applied.");
111
+ break;
112
+ case "skipped_disabled":
113
+ console.log("sessionmem: auto-summarization disabled; retention prune applied.");
114
+ break;
115
+ case "failed":
116
+ console.log("sessionmem: session summarization failed (recorded); retention prune applied.");
117
+ break;
118
+ }
119
+ }
120
+ catch (err) {
121
+ // Best-effort: a failure here must never fail the session-end hook.
122
+ process.stderr.write(`[sessionmem] session-end skipped: ${err instanceof Error ? err.message : String(err)}\n`);
123
+ }
124
+ }
@@ -0,0 +1,52 @@
1
+ import { createCliContext } from "../context.js";
2
+ /**
3
+ * Generic startup query. When few memories contain these literal tokens the
4
+ * FTS pre-filter falls back to the importance/recency candidate set
5
+ * (searchMemoryCandidatesFTS → searchMemoryCandidates), so the injection
6
+ * surfaces the most important and recent memories rather than nothing.
7
+ */
8
+ const STARTUP_QUERY = "session startup context recent decisions architecture warnings preferences";
9
+ /**
10
+ * Emit prior project memories as Claude Code SessionStart hook output.
11
+ *
12
+ * This is the deterministic auto-injection path the user was missing: the
13
+ * installed `SessionStart` hook runs `sessionmem session-start` at the start of
14
+ * every Claude Code session and Claude Code adds this command's
15
+ * `additionalContext` to the conversation — with zero reliance on the agent
16
+ * choosing to call the `startup_inject_memories` tool.
17
+ *
18
+ * Contract notes:
19
+ * - ONLY the JSON envelope is written to stdout; diagnostics never touch
20
+ * stdout (Claude Code parses stdout as the hook result).
21
+ * - The command must never fail a session start: every error is swallowed and
22
+ * results in empty output (the session simply starts without injected
23
+ * context).
24
+ * - When there are no memories yet, nothing is emitted so a fresh project
25
+ * starts clean.
26
+ */
27
+ export async function sessionStartCommand(ctx) {
28
+ try {
29
+ const context = ctx ?? createCliContext();
30
+ const result = await context.service.call("retrieveMemories", {
31
+ projectId: context.projectId,
32
+ query: STARTUP_QUERY,
33
+ limit: 20,
34
+ mode: "auto",
35
+ depth: "default",
36
+ });
37
+ if (result.ok &&
38
+ result.total > 0 &&
39
+ result.startupInjection.trim() !== "") {
40
+ const payload = {
41
+ hookSpecificOutput: {
42
+ hookEventName: "SessionStart",
43
+ additionalContext: result.startupInjection,
44
+ },
45
+ };
46
+ process.stdout.write(JSON.stringify(payload));
47
+ }
48
+ }
49
+ catch {
50
+ // Never block session start — emit nothing on any failure.
51
+ }
52
+ }
@@ -1,8 +1,22 @@
1
1
  import { join } from "path";
2
- import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "fs";
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from "fs";
3
3
  import { configFilePath, readPolicyConfig, } from "../../core/config/policyConfig.js";
4
4
  import { importMemoryRecordSchema } from "../../core/api/contracts.js";
5
5
  import { createCliContext } from "../context.js";
6
+ /**
7
+ * Skip any teammate file larger than this. A network/shared drive can host an
8
+ * arbitrarily large (or maliciously huge) file; reading it whole with
9
+ * `readFileSync` would balloon memory. Skip-and-warn instead, matching the
10
+ * resilient pull semantics for unreadable/non-array files.
11
+ */
12
+ const MAX_TEAMMATE_FILE_BYTES = 10 * 1024 * 1024; // 10MB
13
+ /**
14
+ * `pullMemoriesRequestSchema` rejects any batch over MAX_IMPORT_SIZE (1000)
15
+ * records, so accumulating every teammate's memories into one array would let a
16
+ * large team blow the cap and discard the whole pull. Send in chunks of this
17
+ * size instead.
18
+ */
19
+ const MAX_BATCH = 1000;
6
20
  /**
7
21
  * `sessionmem sync` — push a full snapshot of local project memories to the
8
22
  * shared path and pull every teammate's snapshot back into the local DB.
@@ -70,14 +84,27 @@ export async function syncCommand(ctx, options) {
70
84
  }
71
85
  const memories = [];
72
86
  for (const file of teammateFiles) {
87
+ // file comes from readdirSync(dir) filtered to *.json entries, so it is a
88
+ // plain filename with no path separators, not user input.
89
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
90
+ const filePath = join(dir, file);
91
+ // Guard against an oversized teammate file before reading it whole.
92
+ try {
93
+ const stat = statSync(filePath);
94
+ if (stat.size > MAX_TEAMMATE_FILE_BYTES) {
95
+ console.warn(`sessionmem: skipping ${file} (${stat.size} bytes exceeds 10MB limit)`);
96
+ continue;
97
+ }
98
+ }
99
+ catch {
100
+ console.error(`Skipping unreadable teammate file: ${file}`);
101
+ continue;
102
+ }
73
103
  let parsed;
74
104
  try {
75
105
  // A truncated/corrupt teammate file is skipped-and-warned, never
76
106
  // aborting the rest of the pull.
77
- // file comes from readdirSync(dir) filtered to *.json entries, so it is
78
- // a plain filename with no path separators, not user input.
79
- // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
80
- parsed = JSON.parse(readFileSync(join(dir, file), "utf8"));
107
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
81
108
  }
82
109
  catch {
83
110
  console.error(`Skipping unreadable teammate file: ${file}`);
@@ -100,18 +127,21 @@ export async function syncCommand(ctx, options) {
100
127
  }
101
128
  let pulledNew = 0;
102
129
  let pulledUpdated = 0;
103
- if (memories.length > 0) {
130
+ // Split into batches of MAX_BATCH (1000) so a large team never trips
131
+ // pullMemoriesRequestSchema's max(MAX_IMPORT_SIZE) and discards the whole pull.
132
+ for (let i = 0; i < memories.length; i += MAX_BATCH) {
133
+ const batch = memories.slice(i, i + MAX_BATCH);
104
134
  const pullRes = await context.service.call("pullMemories", {
105
135
  projectId: context.projectId,
106
- memories,
136
+ memories: batch,
107
137
  });
108
138
  if (!pullRes.ok) {
109
139
  console.error(pullRes.error.message);
110
140
  process.exit(1);
111
141
  return;
112
142
  }
113
- pulledNew = pullRes.pulledNew;
114
- pulledUpdated = pullRes.pulledUpdated;
143
+ pulledNew += pullRes.pulledNew;
144
+ pulledUpdated += pullRes.pulledUpdated;
115
145
  }
116
146
  // The exact summary string.
117
147
  console.log(`Pushed ${pushed} memories, pulled ${pulledNew} new + updated ${pulledUpdated} from teammates.`);