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
@@ -2,8 +2,31 @@ import { existsSync, rmSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import { AdapterFactory } from "../../adapters/factory.js";
5
+ import { removeClaudeMdBlock } from "../../adapters/claudeMdInjector.js";
6
+ /**
7
+ * Resolve the adapter to uninstall from: an explicit `--adapter` flag wins,
8
+ * then the SESSIONMEM_ADAPTER env override, else auto-detection. Throws (via
9
+ * AdapterFactory.forName) on an unknown explicit name.
10
+ */
11
+ function resolveUninstallAdapter(options) {
12
+ const explicit = options.adapter && options.adapter.trim() !== ""
13
+ ? options.adapter.trim()
14
+ : process.env.SESSIONMEM_ADAPTER && process.env.SESSIONMEM_ADAPTER.trim() !== ""
15
+ ? process.env.SESSIONMEM_ADAPTER.trim()
16
+ : undefined;
17
+ return explicit
18
+ ? AdapterFactory.forName(explicit)
19
+ : AdapterFactory.detectAdapter();
20
+ }
5
21
  export async function uninstallCommand(options = {}) {
6
- const adapter = AdapterFactory.detectAdapter();
22
+ let adapter;
23
+ try {
24
+ adapter = resolveUninstallAdapter(options);
25
+ }
26
+ catch (err) {
27
+ console.error(err instanceof Error ? err.message : String(err));
28
+ process.exit(1);
29
+ }
7
30
  if (!adapter.uninstall) {
8
31
  console.error(`${adapter.name} does not support automated uninstall. Remove sessionmem from your MCP config manually.`);
9
32
  process.exit(1);
@@ -14,6 +37,19 @@ export async function uninstallCommand(options = {}) {
14
37
  process.exit(1);
15
38
  }
16
39
  console.log(`✓ ${adapter.name} config removed`);
40
+ // Guidance cleanup — non-fatal. Remove the sessionmem block from every file
41
+ // the adapter injected it into at install time. Falls back to the global
42
+ // Claude Code memory file when the adapter declares no targets.
43
+ const guidanceTargets = adapter.guidanceTargets?.() ?? [join(homedir(), ".claude", "CLAUDE.md")];
44
+ for (const target of guidanceTargets) {
45
+ try {
46
+ removeClaudeMdBlock(target);
47
+ console.log(`✓ Agent guidance removed (${target})`);
48
+ }
49
+ catch {
50
+ console.error(`✗ Agent guidance cleanup failed (non-fatal): ${target}`);
51
+ }
52
+ }
17
53
  // Resolve dbPath: use injected override (for tests) or the default location
18
54
  const dbPath = options.dbPath ?? join(homedir(), ".sessionmem", "memories.db");
19
55
  if (options.purge) {
@@ -4,6 +4,7 @@ import { fileURLToPath } from "url";
4
4
  import { mkdirSync } from "fs";
5
5
  import { openDb } from "../core/storage/db.js";
6
6
  import { createMemoryCoreService } from "../core/api/memoryCoreService.js";
7
+ import { deriveProjectId } from "./projectId.js";
7
8
  /**
8
9
  * Resolve the local OS username once per invocation and sanitize it to a
9
10
  * filename-safe token so it can be embedded in exports/filenames without path
@@ -28,29 +29,24 @@ function safeUserInfoName() {
28
29
  return "";
29
30
  }
30
31
  }
31
- function deriveProjectId() {
32
- // Env override is a test-injection seam (mirrors Plan 01's override pattern):
33
- // it lets a spawned binary target a deterministic projectId without touching
34
- // the real ~/.sessionmem. No privilege boundary is crossed the CLI runs as
35
- // the invoking user and the env var is operator-controlled.
36
- const envProjectId = process.env.SESSIONMEM_PROJECT_ID;
37
- if (envProjectId && envProjectId.trim() !== "")
38
- return envProjectId;
39
- const cwd = process.cwd();
40
- const parts = cwd.replace(/\\/g, "/").split("/");
41
- const raw = parts[parts.length - 1] || "default";
42
- // Sanitize to a filename-safe token (mirrors localUsername) so it can be
43
- // embedded in shared-path join()s without path traversal.
44
- const sanitized = raw.replace(/[^A-Za-z0-9._-]/g, "_");
45
- return sanitized === "" || sanitized === "." || sanitized === ".."
46
- ? "default"
47
- : sanitized;
32
+ /**
33
+ * Expand a leading `~/` (or bare `~`) to the user's home directory. Shells do
34
+ * this before a value reaches the process, but env vars set programmatically or
35
+ * in config files are passed through verbatim, so we expand here too.
36
+ */
37
+ export function expandTilde(p) {
38
+ if (p === "~")
39
+ return homedir();
40
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
41
+ return join(homedir(), p.slice(2));
42
+ }
43
+ return p;
48
44
  }
49
45
  function defaultDbPath(dir) {
50
46
  // Env override seam (see deriveProjectId). Defaults to ~/.sessionmem/memories.db.
51
47
  const envDbPath = process.env.SESSIONMEM_DB_PATH;
52
48
  if (envDbPath && envDbPath.trim() !== "")
53
- return envDbPath;
49
+ return expandTilde(envDbPath);
54
50
  // `dir` is the fixed ~/.sessionmem dir computed above, not user input.
55
51
  // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
56
52
  return join(dir, "memories.db");
package/dist/cli/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { Command } from "commander";
3
3
  import { createRequire } from "node:module";
4
4
  import { runMcpServer } from "./commands/run.js";
5
+ import { sessionStartCommand } from "./commands/sessionStart.js";
6
+ import { sessionEndCommand } from "./commands/sessionEnd.js";
5
7
  import { installCommand } from "./commands/install.js";
6
8
  import { uninstallCommand } from "./commands/uninstall.js";
7
9
  import { pingCommand } from "./commands/ping.js";
@@ -12,11 +14,13 @@ import { forgetCommand } from "./commands/forget.js";
12
14
  import { exportCommand } from "./commands/export.js";
13
15
  import { importCommand } from "./commands/import.js";
14
16
  import { statsCommand } from "./commands/stats.js";
17
+ import { savingsCommand } from "./commands/savings.js";
15
18
  import { redactScanCommand } from "./commands/redactScan.js";
16
19
  import { retentionPruneCommand } from "./commands/retention.js";
17
20
  import { configGetCommand, configSetCommand } from "./commands/config.js";
18
21
  import { teamEnableCommand, teamDisableCommand, teamStatusCommand, } from "./commands/team.js";
19
22
  import { syncCommand } from "./commands/sync.js";
23
+ import { reEmbedCommand } from "./commands/reEmbed.js";
20
24
  // Source the version from package.json (single source of truth) so `--version`
21
25
  // never drifts from the published manifest. createRequire + resolveJsonModule
22
26
  // reads the manifest relative to this module; from dist/cli/index.js the
@@ -28,19 +32,29 @@ program
28
32
  .command("run")
29
33
  .description("Start the sessionmem MCP server")
30
34
  .action(runMcpServer);
35
+ program
36
+ .command("session-start")
37
+ .description("Print prior memory context for the current project as Claude Code SessionStart hook output (invoked automatically by the hook installed during `sessionmem install`)")
38
+ .action(() => sessionStartCommand());
39
+ program
40
+ .command("session-end")
41
+ .description("Run the session-end pipeline (auto-summarize ingested session events + light retention prune) for the current project (invoked automatically by the SessionEnd hook installed during `sessionmem install`)")
42
+ .action(() => sessionEndCommand());
31
43
  program
32
44
  .command("install")
33
45
  .description("Install sessionmem into the current MCP host")
34
- .action(installCommand);
46
+ .option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
47
+ .action((options) => installCommand(options));
35
48
  program
36
49
  .command("uninstall")
37
50
  .description("Remove sessionmem from the current MCP host")
38
51
  .option("--purge", "Also delete the local memories database")
39
- .action(uninstallCommand);
52
+ .option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
53
+ .action((options) => uninstallCommand(options));
40
54
  program
41
55
  .command("ping")
42
- .description("Check sessionmem server connectivity")
43
- .action(pingCommand);
56
+ .description("Check sessionmem version and local database reachability")
57
+ .action(() => pingCommand());
44
58
  // NOTE: commander always appends its own Command instance as the final
45
59
  // argument to .action() callbacks. The search/list/show/forget/export/import/
46
60
  // stats commands all declare a trailing `ctx?: CliContext` parameter (the
@@ -81,6 +95,11 @@ program
81
95
  .command("stats")
82
96
  .description("Show memory statistics for the current project")
83
97
  .action(() => statsCommand());
98
+ program
99
+ .command("savings")
100
+ .description("Show token savings from sessionmem compression and injection")
101
+ .option("--json", "Output raw metrics as JSON")
102
+ .action((options) => savingsCommand(undefined, options));
84
103
  // redact-scan — one-time scrub over existing memories. Scan is the
85
104
  // non-destructive default; --apply redacts matching rows in place.
86
105
  program
@@ -134,6 +153,13 @@ team
134
153
  .command("status")
135
154
  .description("Show team mode state and shared-path availability")
136
155
  .action(() => teamStatusCommand());
156
+ // re-embed — bulk-update stale embeddings to the current version.
157
+ // reEmbedCommand declares a trailing `ctx?` test seam, so arrow-wrap to drop
158
+ // commander's trailing Command argument (NOTE above).
159
+ program
160
+ .command("re-embed")
161
+ .description("Re-embed all memories with stale embedding versions")
162
+ .action(() => reEmbedCommand());
137
163
  // sync — push a local snapshot to the shared path and pull every
138
164
  // teammate snapshot back. syncCommand declares a trailing `ctx?` test seam, so
139
165
  // arrow-wrap to drop commander's trailing Command argument (NOTE above).
@@ -1,21 +1,29 @@
1
1
  export function formatTable(rows) {
2
2
  const ID_WIDTH = 36;
3
- const IMP_WIDTH = 10;
3
+ const IMP_WIDTH = 14;
4
+ const ACC_WIDTH = 8;
4
5
  const DATE_WIDTH = 10;
5
- const PREVIEW_WIDTH = 60;
6
+ const PREVIEW_WIDTH = 50;
6
7
  const header = "ID".padEnd(ID_WIDTH) +
7
8
  " | " +
8
9
  "importance".padEnd(IMP_WIDTH) +
9
10
  " | " +
11
+ "accesses".padEnd(ACC_WIDTH) +
12
+ " | " +
10
13
  "date".padEnd(DATE_WIDTH) +
11
14
  " | " +
12
15
  "preview";
13
16
  const lines = rows.map((row) => {
14
17
  const preview = row.content.replace(/\s+/g, " ").slice(0, PREVIEW_WIDTH);
15
18
  const date = row.createdAt.slice(0, 10);
19
+ const imp = row.effectiveImportance !== row.importance
20
+ ? `${row.importance}(${row.effectiveImportance})`
21
+ : String(row.importance);
16
22
  return (row.id.padEnd(ID_WIDTH) +
17
23
  " | " +
18
- String(row.importance).padEnd(IMP_WIDTH) +
24
+ imp.padEnd(IMP_WIDTH) +
25
+ " | " +
26
+ String(row.accessCount).padEnd(ACC_WIDTH) +
19
27
  " | " +
20
28
  date.padEnd(DATE_WIDTH) +
21
29
  " | " +
@@ -0,0 +1,69 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Sanitize a raw token to the filename-safe character set used across
4
+ * sessionmem (mirrors localUsername). Any character outside [A-Za-z0-9._-]
5
+ * becomes "_".
6
+ */
7
+ function sanitizeToken(raw) {
8
+ return raw.replace(/[^A-Za-z0-9._-]/g, "_");
9
+ }
10
+ /**
11
+ * Derive a stable, collision-resistant project id from an absolute working
12
+ * directory.
13
+ *
14
+ * Format: `<basename>-<hash8>` where `hash8` is the first 8 hex chars of the
15
+ * SHA-256 of the FULL absolute path. The human-readable basename keeps the id
16
+ * debuggable; the path hash guarantees two different projects that happen to
17
+ * share a basename (e.g. `~/a/api` and `~/b/api`) get distinct memory buckets
18
+ * instead of silently colliding.
19
+ *
20
+ * The basename-only scheme this replaces partitioned memory purely by the last
21
+ * path segment, so same-named projects in different directories shared one
22
+ * bucket with zero warning.
23
+ */
24
+ export function projectIdFromCwd(cwd) {
25
+ // On Windows the same directory can surface with either a lowercase or
26
+ // uppercase drive letter (e.g. `c:\proj` vs `C:\proj`). Lowercase the drive
27
+ // letter before normalizing slashes so both spellings hash to the same id.
28
+ let normalized = process.platform === "win32"
29
+ ? cwd.replace(/^[A-Za-z]:[\\/]/, (m) => m.toLowerCase()).replace(/\\/g, "/")
30
+ : cwd.replace(/\\/g, "/");
31
+ // Normalize UNC path host and share (//server/share/...) case-insensitively
32
+ // so `\\Server\Share\proj` and `\\server\share\proj` hash to the same id.
33
+ normalized = normalized.replace(/^(\/\/[^/]+\/[^/]+)/, (m) => m.toLowerCase());
34
+ const parts = normalized.split("/");
35
+ const rawBase = parts[parts.length - 1] || "default";
36
+ const sanitizedBase = sanitizeToken(rawBase);
37
+ const base = sanitizedBase === "" || sanitizedBase === "." || sanitizedBase === ".."
38
+ ? "default"
39
+ : sanitizedBase;
40
+ // Hash the normalized absolute path so the id is stable across runs from the
41
+ // same directory but unique per directory.
42
+ const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 8);
43
+ return `${base}-${hash}`;
44
+ }
45
+ /**
46
+ * Resolve the effective project id: the `SESSIONMEM_PROJECT_ID` env override
47
+ * (operator-controlled test/migration seam; also lets a user pin a legacy
48
+ * basename-only id) wins, otherwise derive from `process.cwd()`.
49
+ */
50
+ export function deriveProjectId() {
51
+ const envProjectId = process.env.SESSIONMEM_PROJECT_ID;
52
+ if (envProjectId && envProjectId.trim() !== "") {
53
+ // Sanitize the operator-supplied id to prevent path traversal: the id is
54
+ // later joined into filesystem paths (e.g. sync.ts `join(sharedPath,
55
+ // projectId)`), so strip path separators and `..` traversal sequences and
56
+ // bound the length. Fall through to the derived id only if nothing usable
57
+ // remains.
58
+ const sanitized = envProjectId
59
+ .replace(/[/\\]/g, "")
60
+ .replace(/\.\./g, "")
61
+ .slice(0, 128);
62
+ // Guard against empty or "." (current-dir) values, which would be unsafe or
63
+ // meaningless when joined into filesystem paths; fall through to derived id.
64
+ if (sanitized && sanitized !== ".") {
65
+ return sanitized;
66
+ }
67
+ }
68
+ return projectIdFromCwd(process.cwd());
69
+ }
@@ -1,8 +1,52 @@
1
1
  import { z } from "zod";
2
+ /**
3
+ * Canonical memory-kind taxonomy. This is the single source of truth — the
4
+ * storeMemory tool description, CLAUDE.md guidance block, and
5
+ * formatStartupInjection's KIND_ORDER all use exactly this set. The legacy
6
+ * `architecture` kind is mapped onto `decision` (it was never recognized by the
7
+ * formatter and scored at the bottom).
8
+ */
9
+ export const MEMORY_KINDS = [
10
+ "decision",
11
+ "fact",
12
+ "warning",
13
+ "preference",
14
+ "summary",
15
+ ];
16
+ /**
17
+ * Validate `kind` against {@link MEMORY_KINDS}, transparently mapping the legacy
18
+ * `architecture` value to `decision` so older callers/exports keep working.
19
+ */
20
+ const memoryKindSchema = z.preprocess((value) => (value === "architecture" ? "decision" : value), z.enum(MEMORY_KINDS));
21
+ /** Upper bound on stored memory content length (characters). */
22
+ export const MAX_CONTENT_LENGTH = 10000;
23
+ /** Maximum number of items accepted in a single batchStoreMemory call. */
24
+ export const MAX_BATCH_SIZE = 100;
25
+ /** Maximum number of records accepted in a single import/pull call. */
26
+ export const MAX_IMPORT_SIZE = 1000;
27
+ /**
28
+ * Maximum number of session events accepted in a single ingestSessionEvents
29
+ * call. Each event's payloadJson is capped at 50000 chars, so this bounds a
30
+ * single ingest request at ~25MB worst-case (500 × 50KB) rather than leaving
31
+ * the array unbounded — an agent looping ingestion could otherwise build a
32
+ * multi-MB JSON-RPC message that OOMs or times out the MCP stdio transport.
33
+ * Callers that need to ingest more than this should chunk across calls (the
34
+ * (project_id, session_id, event_index) UNIQUE index makes re-ingestion a
35
+ * no-op, so chunks never double-count).
36
+ */
37
+ export const MAX_INGEST_EVENTS = 500;
38
+ /**
39
+ * Default cap on the number of memories returned by listMemories when the caller
40
+ * omits an explicit `limit`. Bounds the MCP stdio response size for large
41
+ * projects; `total` still reports the full row count. Exported so the service
42
+ * layer and tests share a single source of truth — a change here is caught by
43
+ * the list-memories limit test rather than silently drifting.
44
+ */
45
+ export const LIST_MEMORIES_DEFAULT_LIMIT = 200;
2
46
  export const memorySchema = z.object({
3
47
  id: z.string().min(1),
4
48
  projectId: z.string().min(1),
5
- sessionId: z.string().min(1),
49
+ sessionId: z.string().min(1).max(200),
6
50
  sourceAdapter: z.string().min(1),
7
51
  kind: z.string().min(1),
8
52
  content: z.string().min(1),
@@ -11,27 +55,47 @@ export const memorySchema = z.object({
11
55
  embedding: z.string().nullable(),
12
56
  embeddingDim: z.number().int().nullable(),
13
57
  embeddingVersion: z.string().nullable(),
58
+ accessCount: z.number().int().nonnegative(),
59
+ lastAccessed: z.string().nullable(),
60
+ effectiveImportance: z.number().int().min(1).max(10),
14
61
  createdAt: z.string().min(1),
15
62
  updatedAt: z.string().min(1),
16
63
  });
17
64
  export const ingestSessionEventSchema = z.object({
18
- id: z.string().min(1),
65
+ id: z.string().min(1).max(200),
19
66
  eventIndex: z.number().int().nonnegative(),
20
- eventType: z.string().min(1),
21
- payloadJson: z.string().min(1),
67
+ eventType: z.string().min(1).max(100),
68
+ payloadJson: z
69
+ .string()
70
+ .min(1)
71
+ .max(50000)
72
+ .refine((v) => {
73
+ try {
74
+ JSON.parse(v);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }, { message: "must be valid JSON" }),
22
81
  createdAt: z.string().min(1).optional(),
23
82
  });
24
83
  export const ingestSessionEventsRequestSchema = z.object({
25
84
  projectId: z.string().min(1),
26
- sessionId: z.string().min(1),
27
- events: z.array(ingestSessionEventSchema).min(1),
85
+ sessionId: z.string().min(1).max(200),
86
+ events: z.array(ingestSessionEventSchema).min(1).max(MAX_INGEST_EVENTS),
28
87
  });
29
88
  export const summarizeSessionToMemoryRequestSchema = z.object({
30
- memoryId: z.string().min(1),
89
+ memoryId: z.string().min(1).max(200),
31
90
  projectId: z.string().min(1),
32
- sessionId: z.string().min(1),
33
- sourceAdapter: z.string().min(1),
34
- summary: z.string().min(1),
91
+ sessionId: z.string().min(1).max(200),
92
+ sourceAdapter: z
93
+ .string()
94
+ .min(1)
95
+ .max(100)
96
+ // eslint-disable-next-line no-control-regex -- intentionally rejects control chars
97
+ .regex(/^[^\n\r\x00-\x08\x0e-\x1f\x7f]*$/, "sourceAdapter must not contain control characters"),
98
+ summary: z.string().min(1).max(50000),
35
99
  importance: z.number().int().min(1).max(10),
36
100
  });
37
101
  export const factModeSchema = z.enum([
@@ -42,7 +106,7 @@ export const factModeSchema = z.enum([
42
106
  export const handleSessionEndConfigSchema = z.object({
43
107
  autoSummarize: z.boolean().default(true),
44
108
  minimumEventThreshold: z.number().int().min(1).max(100).default(3),
45
- summaryTokenCap: z.number().int().min(1).default(300),
109
+ summaryTokenCap: z.number().int().min(1).max(200000).default(300),
46
110
  // No `.default()`: omission must be distinguishable from an explicit value so
47
111
  // the service layer can fall back to the policy-config redactionEnabled
48
112
  // setting (override > config.json > default precedence).
@@ -53,18 +117,28 @@ export const handleSessionEndConfigSchema = z.object({
53
117
  });
54
118
  export const handleSessionEndRequestSchema = z.object({
55
119
  projectId: z.string().min(1),
56
- sessionId: z.string().min(1),
57
- sourceAdapter: z.string().min(1),
58
- memoryId: z.string().min(1).optional(),
120
+ sessionId: z.string().min(1).max(200),
121
+ sourceAdapter: z
122
+ .string()
123
+ .min(1)
124
+ .max(100)
125
+ // eslint-disable-next-line no-control-regex -- intentionally rejects control chars
126
+ .regex(/^[^\n\r\x00-\x08\x0e-\x1f\x7f]*$/, "sourceAdapter must not contain control characters"),
127
+ memoryId: z.string().min(1).max(200).optional(),
59
128
  config: handleSessionEndConfigSchema.default(() => handleSessionEndConfigSchema.parse({})),
60
129
  });
61
130
  export const storeMemoryRequestSchema = z.object({
62
- memoryId: z.string().min(1),
131
+ memoryId: z.string().min(1).max(200),
63
132
  projectId: z.string().min(1),
64
- sessionId: z.string().min(1),
65
- sourceAdapter: z.string().min(1),
66
- kind: z.string().min(1),
67
- content: z.string().min(1),
133
+ sessionId: z.string().min(1).max(200),
134
+ sourceAdapter: z
135
+ .string()
136
+ .min(1)
137
+ .max(100)
138
+ // eslint-disable-next-line no-control-regex -- intentionally rejects control chars
139
+ .regex(/^[^\n\r\x00-\x08\x0e-\x1f\x7f]*$/, "sourceAdapter must not contain control characters"),
140
+ kind: memoryKindSchema,
141
+ content: z.string().min(1).max(MAX_CONTENT_LENGTH),
68
142
  importance: z.number().int().min(1).max(10),
69
143
  // No `.default()`: omission must be distinguishable from an explicit value so
70
144
  // the service layer can fall back to the policy-config redactionEnabled
@@ -73,38 +147,43 @@ export const storeMemoryRequestSchema = z.object({
73
147
  });
74
148
  export const retrieveMemoriesRequestSchema = z.object({
75
149
  projectId: z.string().min(1),
76
- query: z.string().min(1),
150
+ query: z.string().min(1).max(1000),
77
151
  limit: z.number().int().min(1).max(100).default(20),
78
152
  mode: z.enum(["auto", "on-demand"]).default("auto"),
79
153
  depth: z.enum(["default", "deep"]).default("default"),
80
154
  });
81
- export const recordMemoryUsedRequestSchema = z.object({
82
- projectId: z.string().min(1),
83
- memoryId: z.string().min(1),
84
- feedbackType: z.enum(["auto_use", "manual"]).default("auto_use"),
85
- usedAt: z.string().min(1).optional(),
86
- });
87
155
  export const listMemoriesRequestSchema = z.object({
88
156
  projectId: z.string().min(1),
157
+ // Maximum number of memories to return. Bounds the response size so a large
158
+ // project (10k+ memories) cannot produce a multi-MB payload that OOMs or
159
+ // times out the MCP stdio client. `total` always reports the full count, so a
160
+ // returned array shorter than `total` signals truncation. Defaults to 200 in
161
+ // the service layer when omitted.
162
+ limit: z.number().int().positive().max(1000).optional(),
89
163
  });
90
164
  export const getMemoryRequestSchema = z.object({
91
165
  projectId: z.string().min(1),
92
- memoryId: z.string().min(1),
166
+ memoryId: z.string().min(1).max(200),
93
167
  });
94
168
  export const forgetMemoryRequestSchema = z.object({
95
169
  projectId: z.string().min(1),
96
- memoryId: z.string().min(1),
170
+ memoryId: z.string().min(1).max(200),
97
171
  });
98
172
  export const exportMemoriesRequestSchema = z.object({
99
173
  projectId: z.string().min(1),
100
174
  });
101
175
  export const importMemoryRecordSchema = z.object({
102
- id: z.string().min(1),
176
+ id: z.string().min(1).max(200),
103
177
  projectId: z.string().min(1),
104
- sessionId: z.string().min(1),
105
- sourceAdapter: z.string().min(1),
106
- kind: z.string().min(1),
107
- content: z.string().min(1),
178
+ sessionId: z.string().min(1).max(200),
179
+ sourceAdapter: z
180
+ .string()
181
+ .min(1)
182
+ .max(100)
183
+ // eslint-disable-next-line no-control-regex -- intentionally rejects control chars
184
+ .regex(/^[^\n\r\x00-\x08\x0e-\x1f\x7f]*$/, "sourceAdapter must not contain control characters"),
185
+ kind: memoryKindSchema,
186
+ content: z.string().min(1).max(MAX_CONTENT_LENGTH),
108
187
  importance: z.number().int().min(1).max(10),
109
188
  // OPTIONAL for backward-compat with exports predating team provenance (A3):
110
189
  // older exports lack author/originProjectId, so the service stamps the local
@@ -112,10 +191,32 @@ export const importMemoryRecordSchema = z.object({
112
191
  // `originProjectId: null` (and `author: ""`) for locally-authored rows — a
113
192
  // team sync round-trips those exported snapshots verbatim, so the schema must
114
193
  // accept the null the DTO carries rather than skip-and-warn every local row.
115
- author: z.string().nullable().optional(),
116
- originProjectId: z.string().nullable().optional(),
117
- createdAt: z.string().min(1).optional(),
118
- updatedAt: z.string().min(1).optional(),
194
+ // `author` is rendered directly into the per-session startup injection
195
+ // context, so it is bounded and stripped of control characters (newlines/CR/
196
+ // C0/DEL) that could break out of the injection block or smuggle in
197
+ // prompt-injection payloads.
198
+ author: z
199
+ .string()
200
+ .max(200)
201
+ .regex(
202
+ // eslint-disable-next-line no-control-regex -- intentionally rejects control chars
203
+ /^[^\n\r\x00-\x08\x0e-\x1f\x7f]*$/, "author must not contain control characters")
204
+ .nullable()
205
+ .optional(),
206
+ // Bounded like the other id-bearing fields (memoryId/author max 200): an
207
+ // import file or team-pull payload is externally-supplied, so an unbounded
208
+ // originProjectId would let a single record carry a multi-MB string. It is
209
+ // stored only (never rendered into the startup injection), so unlike `author`
210
+ // it needs no control-character stripping — just a length cap.
211
+ originProjectId: z.string().max(200).nullable().optional(),
212
+ createdAt: z
213
+ .string()
214
+ .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, "must be UTC ISO timestamp")
215
+ .optional(),
216
+ updatedAt: z
217
+ .string()
218
+ .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, "must be UTC ISO timestamp")
219
+ .optional(),
119
220
  });
120
221
  export const importMemoriesRequestSchema = z.object({
121
222
  projectId: z.string().min(1),
@@ -123,7 +224,7 @@ export const importMemoriesRequestSchema = z.object({
123
224
  // the service layer can fall back to the policy-config redactionEnabled
124
225
  // setting (override > config.json > default precedence).
125
226
  redactionEnabled: z.boolean().optional(),
126
- memories: z.array(importMemoryRecordSchema),
227
+ memories: z.array(importMemoryRecordSchema).max(MAX_IMPORT_SIZE),
127
228
  });
128
229
  // Team pull. Mirrors importMemoriesRequestSchema — the pull
129
230
  // path reuses importMemoryRecordSchema verbatim (carrying author/originProjectId
@@ -133,7 +234,7 @@ export const importMemoriesRequestSchema = z.object({
133
234
  export const pullMemoriesRequestSchema = z.object({
134
235
  projectId: z.string().min(1),
135
236
  redactionEnabled: z.boolean().optional(),
136
- memories: z.array(importMemoryRecordSchema),
237
+ memories: z.array(importMemoryRecordSchema).max(MAX_IMPORT_SIZE),
137
238
  });
138
239
  export const statsRequestSchema = z.object({
139
240
  projectId: z.string().min(1),
@@ -164,6 +265,13 @@ export const redactExistingResponseSchema = z.object({
164
265
  skipped: z.number().int().nonnegative().default(0),
165
266
  previews: z.array(z.string()),
166
267
  });
268
+ export const resetAccessCountsRequestSchema = z.object({
269
+ projectId: z.string().min(1),
270
+ });
271
+ export const resetAccessCountsResponseSchema = z.object({
272
+ ok: z.literal(true),
273
+ affected: z.number().int().nonnegative(),
274
+ });
167
275
  export const operationResultSchema = z.object({
168
276
  ok: z.literal(true),
169
277
  });
@@ -225,7 +333,7 @@ export const ingestSessionEventsResponseSchema = z.object({
225
333
  });
226
334
  export const summarizeSessionToMemoryResponseSchema = z.object({
227
335
  ok: z.literal(true),
228
- memoryId: z.string().min(1),
336
+ memoryId: z.string().min(1).max(200),
229
337
  });
230
338
  export const handleSessionEndResponseSchema = z.object({
231
339
  ok: z.literal(true),
@@ -234,7 +342,7 @@ export const handleSessionEndResponseSchema = z.object({
234
342
  warningCodes: z.array(z.string()),
235
343
  warningMessages: z.array(z.string()),
236
344
  failureRecordId: z.string().min(1).optional(),
237
- memoryId: z.string().min(1).optional(),
345
+ memoryId: z.string().min(1).max(200).optional(),
238
346
  });
239
347
  export const importMemoriesResponseSchema = z.object({
240
348
  ok: z.literal(true),
@@ -243,6 +351,10 @@ export const importMemoriesResponseSchema = z.object({
243
351
  // different project's memory. These are never upserted, preventing
244
352
  // cross-project overwrite/reassignment via ON CONFLICT(id).
245
353
  skippedCrossProject: z.number().int().nonnegative().default(0),
354
+ // Count of records skipped because their `id` already exists in THIS project.
355
+ // Imports never overwrite an existing same-project memory; only brand-new ids
356
+ // are upserted.
357
+ skippedExisting: z.number().int().nonnegative().default(0),
246
358
  warningCodes: z.array(z.string()),
247
359
  });
248
360
  // Team pull response: distinguishes brand-new inserts from updates so the
@@ -255,9 +367,34 @@ export const pullMemoriesResponseSchema = z.object({
255
367
  skippedCrossProject: z.number().int().nonnegative().default(0),
256
368
  warningCodes: z.array(z.string()),
257
369
  });
258
- export const recordMemoryUsedResponseSchema = z.object({
370
+ export const batchStoreMemoryItemSchema = z.object({
371
+ memoryId: z.string().min(1).max(200),
372
+ sessionId: z.string().min(1).max(200),
373
+ sourceAdapter: z
374
+ .string()
375
+ .min(1)
376
+ .max(100)
377
+ // eslint-disable-next-line no-control-regex -- intentionally rejects control chars
378
+ .regex(/^[^\n\r\x00-\x08\x0e-\x1f\x7f]*$/, "sourceAdapter must not contain control characters"),
379
+ kind: memoryKindSchema,
380
+ content: z.string().min(1).max(MAX_CONTENT_LENGTH),
381
+ importance: z.number().int().min(1).max(10),
382
+ redactionEnabled: z.boolean().optional(),
383
+ });
384
+ export const batchStoreMemoryRequestSchema = z.object({
385
+ projectId: z.string().min(1),
386
+ memories: z.array(batchStoreMemoryItemSchema).min(1).max(MAX_BATCH_SIZE),
387
+ });
388
+ export const batchStoreMemoryResultSchema = z.object({
389
+ memoryId: z.string().min(1).max(200),
390
+ ok: z.boolean(),
391
+ memory: memorySchema.optional(),
392
+ warningCodes: z.array(z.string()).optional(),
393
+ error: z.string().optional(),
394
+ });
395
+ export const batchStoreMemoryResponseSchema = z.object({
259
396
  ok: z.literal(true),
260
- memoryId: z.string().min(1),
261
- previousImportance: z.number().int().min(1).max(10),
262
- newImportance: z.number().int().min(1).max(10),
397
+ results: z.array(batchStoreMemoryResultSchema),
398
+ stored: z.number().int().nonnegative(),
399
+ failed: z.number().int().nonnegative(),
263
400
  });
@@ -16,14 +16,11 @@ export function toErrorEnvelope(error) {
16
16
  details: error.details,
17
17
  };
18
18
  }
19
- if (error instanceof Error) {
20
- return {
21
- code: "INTERNAL",
22
- message: error.message,
23
- };
24
- }
19
+ // Don't surface internal .message (may contain fs paths) to MCP client.
20
+ // Log the real message to stderr; return a static string to the caller.
21
+ process.stderr.write(`[sessionmem] internal error: ${error instanceof Error ? error.message : String(error)}\n`);
25
22
  return {
26
23
  code: "INTERNAL",
27
- message: "Unexpected error",
24
+ message: "Internal error",
28
25
  };
29
26
  }