sessionmem 1.1.2 → 1.3.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.
@@ -6,10 +6,12 @@ const UPSERT_INSERT_HEAD = `
6
6
  INSERT INTO memories (
7
7
  id, project_id, session_id, source_adapter, kind, content, normalized_content,
8
8
  importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
9
+ tags, expires_at,
9
10
  created_at, updated_at
10
11
  ) VALUES (
11
12
  @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
12
13
  @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
14
+ @tags, @expires_at,
13
15
  COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
14
16
  COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
15
17
  )
@@ -26,6 +28,8 @@ const UPSERT_INSERT_TAIL = `
26
28
  embedding_version = excluded.embedding_version,
27
29
  author = excluded.author,
28
30
  origin_project_id = excluded.origin_project_id,
31
+ tags = excluded.tags,
32
+ expires_at = excluded.expires_at,
29
33
  created_at = excluded.created_at,
30
34
  updated_at = excluded.updated_at
31
35
  `;
@@ -39,10 +43,12 @@ function getStatements(db) {
39
43
  INSERT INTO memories (
40
44
  id, project_id, session_id, source_adapter, kind, content, normalized_content,
41
45
  importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
46
+ tags, expires_at,
42
47
  created_at, updated_at
43
48
  ) VALUES (
44
49
  @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
45
50
  @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
51
+ @tags, @expires_at,
46
52
  COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
47
53
  COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
48
54
  )
@@ -51,10 +57,12 @@ function getStatements(db) {
51
57
  INSERT INTO memories (
52
58
  id, project_id, session_id, source_adapter, kind, content, normalized_content,
53
59
  importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
60
+ tags, expires_at,
54
61
  created_at, updated_at
55
62
  ) VALUES (
56
63
  @id, @project_id, @session_id, @source_adapter, 'summary', @content, @normalized_content,
57
64
  @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
65
+ @tags, @expires_at,
58
66
  COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
59
67
  COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
60
68
  )
@@ -70,6 +78,8 @@ function getStatements(db) {
70
78
  embedding_version = excluded.embedding_version,
71
79
  author = excluded.author,
72
80
  origin_project_id = excluded.origin_project_id,
81
+ tags = excluded.tags,
82
+ expires_at = excluded.expires_at,
73
83
  updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
74
84
  ON CONFLICT(id)
75
85
  DO UPDATE SET
@@ -84,16 +94,20 @@ function getStatements(db) {
84
94
  embedding_version = excluded.embedding_version,
85
95
  author = excluded.author,
86
96
  origin_project_id = excluded.origin_project_id,
97
+ tags = excluded.tags,
98
+ expires_at = excluded.expires_at,
87
99
  updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
88
100
  `),
89
101
  listByProject: db.prepare(`
90
102
  SELECT
91
103
  id, project_id, session_id, source_adapter, kind, content, normalized_content,
92
104
  importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
105
+ tags, expires_at,
93
106
  access_count, last_accessed, created_at, updated_at
94
107
  FROM memories
95
108
  WHERE project_id = ?
96
- ORDER BY updated_at DESC
109
+ AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
110
+ ORDER BY updated_at DESC, id ASC
97
111
  `),
98
112
  // Lightweight projection for token-savings accounting: only `content` is
99
113
  // needed to count tokens, so we deliberately avoid pulling the (potentially
@@ -110,6 +124,7 @@ function getStatements(db) {
110
124
  SELECT
111
125
  id, project_id, session_id, source_adapter, kind, content, normalized_content,
112
126
  importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
127
+ tags, expires_at,
113
128
  access_count, last_accessed, created_at, updated_at
114
129
  FROM memories
115
130
  WHERE project_id = ? AND id = ?
@@ -194,6 +209,8 @@ function toParams(input) {
194
209
  embedding_version: input.embedding_version ?? null,
195
210
  author: input.author ?? "",
196
211
  origin_project_id: input.origin_project_id ?? null,
212
+ tags: input.tags ?? null,
213
+ expires_at: input.expires_at ?? null,
197
214
  created_at: input.created_at ?? null,
198
215
  updated_at: input.updated_at ?? null,
199
216
  };
@@ -1,22 +1,36 @@
1
1
  import { EMBEDDING_VERSION } from "../embed/embeddingVersion.js";
2
2
  const FTS_CANDIDATE_LIMIT = 50;
3
3
  const FTS_FALLBACK_THRESHOLD = 5;
4
+ /** Recency window for the fallback candidate scan. */
5
+ const FALLBACK_RECENCY_DAYS = 90;
6
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
7
+ /** Storage timestamp format: ISO-8601 UTC with millisecond precision. */
8
+ function toStorageTimestamp(date) {
9
+ return date.toISOString();
10
+ }
4
11
  const searchStmtCache = new WeakMap();
5
12
  function getSearchStatements(db) {
6
13
  let stmts = searchStmtCache.get(db);
7
14
  if (stmts)
8
15
  return stmts;
9
16
  stmts = {
17
+ // `now` and the recency cutoff are bound parameters rather than
18
+ // strftime('now') so the caller's clock drives both filters. With SQL's own
19
+ // clock, a caller that passes an explicit `now` (retrieveMemories, and every
20
+ // test that pins a date) silently got a different window than it asked for,
21
+ // and fixed-date fixtures aged out of the 90-day band over real calendar
22
+ // time — turning deterministic tests into time bombs.
10
23
  searchCandidates: db.prepare(`
11
24
  SELECT
12
25
  id, project_id, session_id, source_adapter, kind, content, normalized_content,
13
26
  importance, author, origin_project_id, access_count, created_at, updated_at,
14
27
  embedding, embedding_dim, embedding_version
15
28
  FROM memories
16
- WHERE project_id = ?
29
+ WHERE project_id = @projectId
30
+ AND (expires_at IS NULL OR expires_at > @now)
17
31
  AND (
18
32
  importance >= 8
19
- OR updated_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days')
33
+ OR updated_at > @recencyCutoff
20
34
  )
21
35
  `),
22
36
  searchCandidatesFTS: db.prepare(`
@@ -27,10 +41,11 @@ function getSearchStatements(db) {
27
41
  m.embedding, m.embedding_dim, m.embedding_version
28
42
  FROM memories_fts
29
43
  JOIN memories m ON m.rowid = memories_fts.rowid
30
- WHERE memories_fts MATCH ?
31
- AND m.project_id = ?
44
+ WHERE memories_fts MATCH @match
45
+ AND m.project_id = @projectId
46
+ AND (m.expires_at IS NULL OR m.expires_at > @now)
32
47
  ORDER BY rank
33
- LIMIT ?
48
+ LIMIT @limit
34
49
  `),
35
50
  };
36
51
  searchStmtCache.set(db, stmts);
@@ -72,8 +87,12 @@ function mapRows(rows) {
72
87
  };
73
88
  });
74
89
  }
75
- export function searchMemoryCandidates(db, projectId) {
76
- const rows = getSearchStatements(db).searchCandidates.all(projectId);
90
+ export function searchMemoryCandidates(db, projectId, now = new Date()) {
91
+ const rows = getSearchStatements(db).searchCandidates.all({
92
+ projectId,
93
+ now: toStorageTimestamp(now),
94
+ recencyCutoff: toStorageTimestamp(new Date(now.getTime() - FALLBACK_RECENCY_DAYS * MS_PER_DAY)),
95
+ });
77
96
  return mapRows(rows);
78
97
  }
79
98
  /**
@@ -105,23 +124,28 @@ function sanitizeFtsQuery(queryText) {
105
124
  * (<8) — exactly the rows the fallback's filter excludes — returning zero
106
125
  * candidates for a query that matched only such rows.
107
126
  */
108
- export function searchMemoryCandidatesFTS(db, projectId, queryText) {
127
+ export function searchMemoryCandidatesFTS(db, projectId, queryText, now = new Date()) {
109
128
  const sanitized = sanitizeFtsQuery(queryText);
110
129
  if (!sanitized) {
111
- return searchMemoryCandidates(db, projectId);
130
+ return searchMemoryCandidates(db, projectId, now);
112
131
  }
113
132
  let rows;
114
133
  try {
115
- rows = getSearchStatements(db).searchCandidatesFTS.all(sanitized, projectId, FTS_CANDIDATE_LIMIT);
134
+ rows = getSearchStatements(db).searchCandidatesFTS.all({
135
+ match: sanitized,
136
+ projectId,
137
+ now: toStorageTimestamp(now),
138
+ limit: FTS_CANDIDATE_LIMIT,
139
+ });
116
140
  }
117
141
  catch {
118
142
  // FTS5 MATCH can throw on malformed queries — fall back to full scan
119
- return searchMemoryCandidates(db, projectId);
143
+ return searchMemoryCandidates(db, projectId, now);
120
144
  }
121
145
  if (rows.length >= FTS_FALLBACK_THRESHOLD) {
122
146
  return dedupById(mapRows(rows));
123
147
  }
124
- const fallback = searchMemoryCandidates(db, projectId);
148
+ const fallback = searchMemoryCandidates(db, projectId, now);
125
149
  if (rows.length === 0) {
126
150
  return fallback;
127
151
  }
@@ -22,6 +22,11 @@ function getSessionEventsStatements(db) {
22
22
  ORDER BY event_index ASC
23
23
  `),
24
24
  countAll: db.prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?"),
25
+ nextEventIndex: db.prepare(`
26
+ SELECT COALESCE(MAX(event_index), -1) + 1 AS next
27
+ FROM session_events
28
+ WHERE project_id = ? AND session_id = ?
29
+ `),
25
30
  };
26
31
  sessionEventsStmtCache.set(db, stmts);
27
32
  return stmts;
@@ -34,6 +39,22 @@ function getSessionEventsStatements(db) {
34
39
  export function insertSessionEvent(db, input) {
35
40
  return getSessionEventsStatements(db).insertEvent.run(input).changes;
36
41
  }
42
+ /**
43
+ * Next free `event_index` for a session (max + 1, or 0 when the session has no
44
+ * events yet).
45
+ *
46
+ * Callers that have no natural ordering key of their own (the PostToolUse hook
47
+ * fires once per tool use, in separate processes) use this instead of inventing
48
+ * one. A wall-clock index collides whenever two tool uses land in the same
49
+ * millisecond — which parallel tool calls routinely do — and the UNIQUE index
50
+ * on (project_id, session_id, event_index) then makes `INSERT OR IGNORE` drop
51
+ * the loser silently. Must be called inside the same immediate transaction as
52
+ * the insert so concurrent writers cannot read the same value.
53
+ */
54
+ export function nextSessionEventIndex(db, projectId, sessionId) {
55
+ const row = getSessionEventsStatements(db).nextEventIndex.get(projectId, sessionId);
56
+ return row.next;
57
+ }
37
58
  export function countAllSessionEvents(db, projectId) {
38
59
  const row = getSessionEventsStatements(db).countAll.get(projectId);
39
60
  return row.count;
@@ -9,6 +9,43 @@
9
9
  // applyRedaction invocation — a measurable saving in batch/import/pull loops.
10
10
  // The regex literals carry no `lastIndex` state because every pattern is used
11
11
  // with String.prototype.replace (not stateful .test()/.exec() on a shared regex).
12
+ /**
13
+ * Key names that mark an assignment's value as a secret. Matched
14
+ * case-insensitively and allowed to carry bounded affixes on either side, so
15
+ * `AWS_SECRET_ACCESS_KEY` and `SECRET_TOKEN_VALUE` match as readily as `secret`.
16
+ */
17
+ const SECRET_KEY_NAMES = [
18
+ "password",
19
+ "passwd",
20
+ "pwd",
21
+ "secret",
22
+ "api[_-]?key",
23
+ "apikey",
24
+ "access[_-]?token",
25
+ "refresh[_-]?token",
26
+ "auth[_-]?token",
27
+ "client[_-]?secret",
28
+ "private[_-]?key",
29
+ "credentials?",
30
+ "token",
31
+ ].join("|");
32
+ /**
33
+ * `<key><separator><value>` where the key names a secret. Capture groups are
34
+ * (key, separator, value) so the replacement can keep the key and separator
35
+ * verbatim and swap only the value.
36
+ *
37
+ * Both `:` and `=` are accepted (YAML, JSON, `.env`, query strings, log lines),
38
+ * and the value may be backslash-escaped-quoted, double-quoted, single-quoted,
39
+ * or bare. Every quantifier is bounded, so the pattern is ReDoS-safe.
40
+ *
41
+ * The escaped-quote branch comes first and the bare branch excludes `\`, which
42
+ * together keep a JSON payload parseable after redaction. Session events store
43
+ * JSON, so a secret inside a stringified field arrives as `password=\"a b\"`;
44
+ * letting the bare branch match the lone backslash replaced it with
45
+ * `[REDACTED]` and left the rest of the quoted run stranded, producing
46
+ * unparseable JSON.
47
+ */
48
+ const SECRET_ASSIGNMENT_PATTERN = new RegExp(String.raw `\b([A-Za-z0-9_.-]{0,40}(?:${SECRET_KEY_NAMES})[A-Za-z0-9_.-]{0,40}\\?"?)(\s*[:=]\s*)(\\"(?:\\.|[^"\\]){0,4096}\\"|"[^"\n]{1,4096}"|'[^'\n]{1,4096}'|[^\s"'\\,;&}]{1,4096})`, "gi");
12
49
  const DEFAULT_RULES = createDefaultRules();
13
50
  function createDefaultRules() {
14
51
  return [
@@ -49,16 +86,39 @@ function createDefaultRules() {
49
86
  (input) => input.replace(/\bsk-(?:[a-zA-Z0-9]{1,32}-){0,4}[a-zA-Z0-9]{12,200}\b/g, "[REDACTED_API_KEY]"),
50
87
  // Bearer token header value: "Bearer <token>" (case-insensitive), token redacted.
51
88
  (input) => input.replace(/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, "Bearer [REDACTED_BEARER_TOKEN]"),
52
- // Config/connection-string assignments: key=value where key is a known
53
- // secret-bearing name. The key is kept and the value redacted. Covers
54
- // password/secret plus api_key/apikey/token/access_token/pwd in URL query
55
- // strings and config files. The `=` separator may carry surrounding spaces.
56
- (input) => input.replace(/\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|token)\s*=\s*([^\s"'&;]+)/gi, "$1=[REDACTED]"),
57
- // JSON-form secret assignments: "key": "value" for known secret-bearing
58
- // keys. Complements the key=value rule above so secrets embedded in JSON
59
- // payloads (config files, logged request bodies) are redacted too. The key
60
- // is preserved; the quoted value is collapsed.
61
- (input) => input.replace(/"(password|secret|api_key|token|access_token|auth_token|client_secret|private_key|pwd|passwd)"\s*:\s*"[^"]{4,}"/gi, '"$1": "[REDACTED]"'),
89
+ // HTTP Basic auth header: the base64 blob encodes user:password verbatim.
90
+ (input) => input.replace(/\bBasic\s+[A-Za-z0-9+/]{12,}={0,2}/g, "Basic [REDACTED_BASIC_AUTH]"),
91
+ // Slack incoming-webhook URLs — the path IS the credential, so the whole
92
+ // path is collapsed rather than a token-shaped substring of it.
93
+ (input) => input.replace(/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]{10,200}/g, "https://hooks.slack.com/services/[REDACTED_SLACK_WEBHOOK]"),
94
+ // Secret-bearing assignments: `<key><sep><value>` for a known secret-ish key
95
+ // name, covering config files, .env exports, YAML, JSON bodies, log lines
96
+ // and URL query strings in one rule.
97
+ //
98
+ // Three things the narrower predecessors missed, each a real leak:
99
+ // - `:` as a separator (YAML, JSON, `Header: value` log lines). Only `=`
100
+ // was handled, so `aws_secret_access_key: wJalr…` passed through.
101
+ // - Affixed key names. The keyword had to sit on a \b boundary, and `_` is
102
+ // a word character, so `AWS_SECRET_ACCESS_KEY` and `SECRET_TOKEN_VALUE`
103
+ // never matched. Allow bounded affixes on both sides.
104
+ // - Quoted values. The value class excluded quotes, so `password="a b c"`
105
+ // matched nothing at all.
106
+ //
107
+ // Quoted values keep their delimiters so a redacted JSON payload stays
108
+ // parseable. All quantifiers are bounded and the alternation is
109
+ // non-backtracking on failure, so the rule stays ReDoS-safe.
110
+ (input) => input.replace(SECRET_ASSIGNMENT_PATTERN, (_match, key, separator, value) => {
111
+ // Preserve the value's quote style so a redacted JSON payload stays
112
+ // parseable and a redacted YAML line stays well-formed.
113
+ const quote = value.startsWith('\\"')
114
+ ? '\\"'
115
+ : value.startsWith('"')
116
+ ? '"'
117
+ : value.startsWith("'")
118
+ ? "'"
119
+ : "";
120
+ return `${key}${separator}${quote}[REDACTED]${quote}`;
121
+ }),
62
122
  ];
63
123
  }
64
124
  export function applyRedaction(input, options) {
@@ -1,3 +1,12 @@
1
+ /** Max distinct lines rendered per section. */
2
+ const MAX_SECTION_LINES = 5;
3
+ /** Max `- [type] text` fact lines in the facts section. */
4
+ const MAX_FACT_LINES = 8;
5
+ /**
6
+ * Human-readable text for an event. The auto-ingest hook writes a `text` field
7
+ * carrying a one-line description ("Ran: npm test", "Edited src/foo.ts"); fall
8
+ * back to the raw payload for events written by other producers.
9
+ */
1
10
  function extractEventText(event) {
2
11
  try {
3
12
  const parsed = JSON.parse(event.payload_json);
@@ -10,34 +19,55 @@ function extractEventText(event) {
10
19
  }
11
20
  return event.payload_json.trim();
12
21
  }
13
- function sectionLines(events, fallback) {
14
- const lines = events.map(extractEventText).filter((line) => line.length > 0);
15
- if (lines.length === 0) {
16
- return [fallback];
22
+ /** Distinct, non-empty lines, preserving first-seen order. */
23
+ function distinctLines(events) {
24
+ const seen = new Set();
25
+ const lines = [];
26
+ for (const event of events) {
27
+ const line = extractEventText(event);
28
+ if (line.length === 0 || seen.has(line))
29
+ continue;
30
+ seen.add(line);
31
+ lines.push(line);
17
32
  }
18
- return lines.slice(0, 3);
33
+ return lines;
19
34
  }
35
+ function section(title, lines, fallback) {
36
+ const body = lines.length > 0 ? lines : [fallback];
37
+ return [title, ...body.map((line) => `- ${line}`)];
38
+ }
39
+ /**
40
+ * Render the session's events as a structured summary.
41
+ *
42
+ * Sections carry DIFFERENT slices of the session rather than the same lines
43
+ * repeated under every heading: `actions` is what happened in order, `outcomes`
44
+ * is where the session ended up, and `decisions` surfaces the events that
45
+ * recorded a choice (git commits). Repeating one slice under all five headings
46
+ * produced a summary that cost five times the tokens and carried no extra
47
+ * signal — and, once stored, was what every later session got injected with.
48
+ */
20
49
  export function buildStructuredSummary(events, options) {
21
- const sharedLines = sectionLines(events, "No relevant events captured.");
50
+ const lines = distinctLines(events);
51
+ // Ordered narrative of the session, capped from the front.
52
+ const actions = lines.slice(0, MAX_SECTION_LINES);
53
+ // Where the session ended up: the tail, which is the most recent state.
54
+ const outcomes = lines.length > MAX_SECTION_LINES
55
+ ? lines.slice(-Math.min(MAX_SECTION_LINES, lines.length - MAX_SECTION_LINES))
56
+ : lines.slice(-1);
57
+ // Commits are the durable decisions a session leaves behind.
58
+ const decisions = lines
59
+ .filter((line) => /\bgit\s+commit\b/i.test(line))
60
+ .slice(0, MAX_SECTION_LINES);
22
61
  const summarySection = [
23
- "goals",
24
- ...sharedLines.map((line) => `- ${line}`),
25
- "",
26
- "actions",
27
- ...sharedLines.map((line) => `- ${line}`),
28
- "",
29
- "decisions",
30
- ...sharedLines.map((line) => `- ${line}`),
62
+ ...section("actions", actions, "No relevant events captured."),
31
63
  "",
32
- "blockers",
33
- ...sharedLines.map((line) => `- ${line}`),
64
+ ...section("decisions", decisions, "No explicit decisions recorded."),
34
65
  "",
35
- "outcomes",
36
- ...sharedLines.map((line) => `- ${line}`),
66
+ ...section("outcomes", outcomes, "No outcomes captured."),
37
67
  ].join("\n");
38
68
  const facts = events
39
- .map((event) => `- [${event.event_type}] ${extractEventText(event)}`)
40
- .slice(0, 5);
69
+ .slice(0, MAX_FACT_LINES)
70
+ .map((event) => `- [${event.event_type}] ${extractEventText(event)}`);
41
71
  const factSection = ["facts", ...facts].join("\n");
42
72
  if (options.factMode === "summary-only") {
43
73
  return summarySection;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessionmem",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local-first MCP memory layer for coding agents across Claude Code, Codex, Cursor, Cline, Windsurf, and other MCP-compatible hosts.",
@@ -28,10 +28,11 @@
28
28
  "lint": "eslint .",
29
29
  "typecheck": "tsc --noEmit",
30
30
  "benchmark": "node scripts/benchmark.mjs",
31
- "postversion": "node -e \"const v=require('./package.json').version; ['package/package.json','.claude-plugin/plugin.json','server.json'].forEach(f=>{const o=require('./'+f);o.version=v;if(o.packages&&o.packages[0])o.packages[0].version=v;require('fs').writeFileSync('./'+f,JSON.stringify(o,null,2)+'\\n')})\" && npm run build"
31
+ "version": "node scripts/sync-version.mjs && git add package/package.json .claude-plugin/plugin.json server.json",
32
+ "postversion": "npm run build"
32
33
  },
33
34
  "dependencies": {
34
- "@anthropic-ai/sdk": "^0.105.0",
35
+ "@anthropic-ai/sdk": "^0.107.0",
35
36
  "@modelcontextprotocol/sdk": "^1.29.0",
36
37
  "better-sqlite3": "^12.4.1",
37
38
  "commander": "^15.0.0",
@@ -1,4 +0,0 @@
1
- export function onSessionDisconnect(adapterName, error) {
2
- const reason = error ? error.message : "unknown cause";
3
- console.warn(`[sessionmem] ${adapterName} disconnected mid-session (${reason}). Continuing.`);
4
- }