@marsnme/mcp-gateway 0.2.1 → 0.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.
@@ -31,15 +31,15 @@ ARTIFACT_PATH="${OUTPUT_DIR}/${ARTIFACT_BASENAME}.tar.gz"
31
31
  MANIFEST_PATH="${OUTPUT_DIR}/${ARTIFACT_BASENAME}.manifest.json"
32
32
 
33
33
  tar -C "${REPO_ROOT}" -czf "${ARTIFACT_PATH}" \
34
- --exclude='soul-memory/deploy/artifacts' \
35
- --exclude='soul-memory/deploy/phase0/ct101-baseline-*' \
34
+ --exclude='marsnme-supabase/deploy/artifacts' \
35
+ --exclude='marsnme-supabase/deploy/phase0/ct101-baseline-*' \
36
36
  README.md \
37
- soul-memory \
37
+ marsnme-supabase \
38
38
  supabase/config.toml \
39
39
  supabase/migrations
40
40
 
41
41
  ARTIFACT_SHA256="$(shasum -a 256 "${ARTIFACT_PATH}" | awk '{print $1}')"
42
- SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/soul-memory/server.mjs" | awk '{print $1}')"
42
+ SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/marsnme-supabase/server.mjs" | awk '{print $1}')"
43
43
 
44
44
  cat > "${MANIFEST_PATH}" <<EOF
45
45
  {
@@ -47,7 +47,7 @@ cat > "${MANIFEST_PATH}" <<EOF
47
47
  "commit_hash": "${COMMIT_HASH}",
48
48
  "artifact_path": "${ARTIFACT_PATH}",
49
49
  "artifact_sha256": "${ARTIFACT_SHA256}",
50
- "server_path": "soul-memory/server.mjs",
50
+ "server_path": "marsnme-supabase/server.mjs",
51
51
  "server_sha256": "${SERVER_SHA256}"
52
52
  }
53
53
  EOF
@@ -151,7 +151,7 @@ for profile in "${PROFILE_LIST[@]}"; do
151
151
  profile_ident="$(quote_ident "${profile}")"
152
152
 
153
153
  check_sql "${profile}.memories required columns" \
154
- "SELECT id,body,source,session_id,tags,agent_body,environment,promoted,promoted_at,created_at,expires_at FROM ${profile_ident}.memories LIMIT 0;"
154
+ "SELECT id,body,source,session_id,tags,agent_body,environment,promoted,promoted_at,created_at,expires_at,recipient_body,note,read_at FROM ${profile_ident}.memories LIMIT 0;"
155
155
 
156
156
  check_sql "${profile}.marsvault_chunks required columns" \
157
157
  "SELECT id,content,source_file,section,body,visibility,tags,type,date,origin,source_memory_id,source_session_id,source_tool,source_user_note,agent_body,environment,created_at,updated_at FROM ${profile_ident}.marsvault_chunks LIMIT 0;"
@@ -112,9 +112,9 @@ mcp_jsonrpc() {
112
112
  if [[ "${SPAWN_LOCAL}" == "true" ]]; then
113
113
  COCO_LOG="/tmp/mars-memory-coco-smoke.log"
114
114
  TOTO_LOG="/tmp/mars-memory-toto-smoke.log"
115
- MCP_PROFILE=coco PORT=18790 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/soul-memory/server.mjs" >"${COCO_LOG}" 2>&1 &
115
+ MCP_PROFILE=coco PORT=18790 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/marsnme-supabase/server.mjs" >"${COCO_LOG}" 2>&1 &
116
116
  COCO_PID="$!"
117
- MCP_PROFILE=toto PORT=18791 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/soul-memory/server.mjs" >"${TOTO_LOG}" 2>&1 &
117
+ MCP_PROFILE=toto PORT=18791 SUPABASE_BASE_URL="${SUPABASE_BASE_URL_FOR_LOCAL}" MCP_REQUIRE_BEARER=false node "${REPO_ROOT}/marsnme-supabase/server.mjs" >"${TOTO_LOG}" 2>&1 &
118
118
  TOTO_PID="$!"
119
119
  sleep 2
120
120
  fi
@@ -131,7 +131,8 @@ COCO_TOOLS_JSON="$(mcp_jsonrpc "${COCO_URL}" '{"jsonrpc":"2.0","id":"coco-tools-
131
131
  TOTO_TOOLS_JSON="$(mcp_jsonrpc "${TOTO_URL}" '{"jsonrpc":"2.0","id":"toto-tools-list","method":"tools/list","params":{}}')"
132
132
 
133
133
  COCO_TOOLS_CHECK="$(TOOLS_PAYLOAD="${COCO_TOOLS_JSON}" node -e '
134
- const expected = ["insert_memory", "list_memories", "search_memories", "recall", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest"];
134
+ const expected = ["insert_memory", "list_memories", "search_memories", "reload_source_registry", "recall", "get_summary", "get_full", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest", "demote_memory", "soft_forget", "explain_memory", "batch_promote"];
135
+ const prdTools = ["save_prd", "get_prd", "list_prds", "score_prd", "spawn_to_linear"];
135
136
  const payload = JSON.parse(process.env.TOOLS_PAYLOAD || "{}");
136
137
  if (payload.error) throw new Error("tools/list error: " + JSON.stringify(payload.error));
137
138
  const tools = payload.result?.tools;
@@ -139,11 +140,14 @@ if (!Array.isArray(tools)) throw new Error("tools/list missing result.tools");
139
140
  const names = tools.map((item) => item?.name).filter(Boolean);
140
141
  const missing = expected.filter((name) => !names.includes(name));
141
142
  if (missing.length > 0) throw new Error("missing tools: " + missing.join(","));
143
+ const leaked = names.filter((name) => prdTools.includes(name));
144
+ if (leaked.length > 0) throw new Error("PRD tools must not be exposed: " + leaked.join(","));
142
145
  process.stdout.write(JSON.stringify({ tool_count: names.length, tools: names }));
143
146
  ')"
144
147
 
145
148
  TOTO_TOOLS_CHECK="$(TOOLS_PAYLOAD="${TOTO_TOOLS_JSON}" node -e '
146
- const expected = ["insert_memory", "list_memories", "search_memories", "recall", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest"];
149
+ const expected = ["insert_memory", "list_memories", "search_memories", "reload_source_registry", "recall", "get_summary", "get_full", "health_check", "session_boot", "session_close", "dream_ingest", "memory_ingest", "demote_memory", "soft_forget", "explain_memory", "batch_promote"];
150
+ const prdTools = ["save_prd", "get_prd", "list_prds", "score_prd", "spawn_to_linear"];
147
151
  const payload = JSON.parse(process.env.TOOLS_PAYLOAD || "{}");
148
152
  if (payload.error) throw new Error("tools/list error: " + JSON.stringify(payload.error));
149
153
  const tools = payload.result?.tools;
@@ -151,6 +155,8 @@ if (!Array.isArray(tools)) throw new Error("tools/list missing result.tools");
151
155
  const names = tools.map((item) => item?.name).filter(Boolean);
152
156
  const missing = expected.filter((name) => !names.includes(name));
153
157
  if (missing.length > 0) throw new Error("missing tools: " + missing.join(","));
158
+ const leaked = names.filter((name) => prdTools.includes(name));
159
+ if (leaked.length > 0) throw new Error("PRD tools must not be exposed: " + leaked.join(","));
154
160
  process.stdout.write(JSON.stringify({ tool_count: names.length, tools: names }));
155
161
  ')"
156
162
 
@@ -261,7 +267,7 @@ if [[ -z "${OUTPUT_PATH}" ]]; then
261
267
  fi
262
268
 
263
269
  COMMIT_HASH="$(git -C "${REPO_ROOT}" rev-parse HEAD)"
264
- LOCAL_SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/soul-memory/server.mjs" | awk '{print $1}')"
270
+ LOCAL_SERVER_SHA256="$(shasum -a 256 "${REPO_ROOT}/marsnme-supabase/server.mjs" | awk '{print $1}')"
265
271
 
266
272
  if [[ -n "${REMOTE_SERVER_SHA256}" ]]; then
267
273
  REMOTE_SERVER_SHA256_VALUE="\"${REMOTE_SERVER_SHA256}\""
@@ -6,19 +6,19 @@ Requires=docker.service
6
6
 
7
7
  [Service]
8
8
  Type=simple
9
- WorkingDirectory=/opt/mars-memory-mcp/current-%i/soul-memory
9
+ WorkingDirectory=/opt/mars-memory-mcp/current-%i/marsnme-supabase
10
10
  EnvironmentFile=-/opt/mars-memory-mcp/shared/.env
11
11
  EnvironmentFile=-/opt/mars-memory-mcp/shared/.env.%i
12
- EnvironmentFile=-/opt/mars-memory-mcp/current-%i/soul-memory/.env
13
- EnvironmentFile=-/opt/mars-memory-mcp/current-%i/soul-memory/.env.%i
14
- EnvironmentFile=-/opt/mars-memory-mcp/current/soul-memory/.env
15
- EnvironmentFile=-/opt/mars-memory-mcp/current/soul-memory/.env.%i
12
+ EnvironmentFile=-/opt/mars-memory-mcp/current-%i/marsnme-supabase/.env
13
+ EnvironmentFile=-/opt/mars-memory-mcp/current-%i/marsnme-supabase/.env.%i
14
+ EnvironmentFile=-/opt/mars-memory-mcp/current/marsnme-supabase/.env
15
+ EnvironmentFile=-/opt/mars-memory-mcp/current/marsnme-supabase/.env.%i
16
16
  Environment=MCP_PROFILE=%i
17
17
  Environment=MCP_REQUIRE_BEARER=true
18
18
  Environment=JINA_EMBEDDING_API_URL=https://api.jina.ai/v1/embeddings
19
19
  Environment=JINA_EMBEDDING_MODEL=jina-embeddings-v3
20
20
  Environment=JINA_EMBEDDING_DIMENSIONS=1024
21
- ExecStart=/usr/bin/node /opt/mars-memory-mcp/current-%i/soul-memory/server.mjs
21
+ ExecStart=/usr/bin/node /opt/mars-memory-mcp/current-%i/marsnme-supabase/server.mjs
22
22
  Restart=always
23
23
  RestartSec=3
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsnme/mcp-gateway",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "mcpName": "io.github.Marsmanleo/marsnme",
5
5
  "private": false,
6
6
  "description": "Agent-agnostic, LLM-agnostic memory backend for MCP-compatible tools",
@@ -8,8 +8,8 @@ from typing import Optional
8
8
 
9
9
 
10
10
  ROOT = Path(__file__).resolve().parents[3]
11
- DREAM_RUNNER = ROOT / "soul-memory" / "scripts" / "dream_runner.py"
12
- HERMES_WRAPPER = ROOT / "soul-memory" / "scripts" / "hermes_digest_runner.py"
11
+ DREAM_RUNNER = ROOT / "marsnme-supabase" / "scripts" / "dream_runner.py"
12
+ HERMES_WRAPPER = ROOT / "marsnme-supabase" / "scripts" / "hermes_digest_runner.py"
13
13
 
14
14
 
15
15
  class DreamRunnerModeTests(unittest.TestCase):
package/server.mjs CHANGED
@@ -13,7 +13,7 @@ const PROFILE_CONFIGS = {
13
13
  defaultPort: 18790,
14
14
  gatewayDir: 'coco-mcp-gateway',
15
15
  publicHostSuffix: 'coco-mcp.marsgroup.asia',
16
- sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes'],
16
+ sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes', 'draft', 'grok'],
17
17
  recallBodyEnum: ['coco', 'toto', 'system'],
18
18
  digestDefaultOrigin: 'hermes-coco-digest',
19
19
  memoryIngestToolName: 'memory_ingest',
@@ -35,7 +35,7 @@ const PROFILE_CONFIGS = {
35
35
  defaultPort: 18791,
36
36
  gatewayDir: 'toto-mcp-gateway',
37
37
  publicHostSuffix: 'toto-mcp.marsgroup.asia',
38
- sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw'],
38
+ sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'grok'],
39
39
  recallBodyEnum: ['toto', 'system'],
40
40
  digestDefaultOrigin: 'hermes-toto-digest',
41
41
  memoryIngestToolName: 'memory_ingest',
@@ -190,9 +190,13 @@ const ANON_ALLOWED_TOOL_NAMES = new Set([
190
190
  'search_memories',
191
191
  'reload_source_registry',
192
192
  'recall',
193
+ 'get_summary',
194
+ 'get_full',
193
195
  'health_check',
194
196
  'explain_memory'
195
197
  ]);
198
+ const RECALL_PREVIEW_MAX_CHARS = 80;
199
+ const CHUNK_SUMMARY_MAX_CHARS = 300;
196
200
  const SUPABASE_REQUEST_CONTEXT = new AsyncLocalStorage();
197
201
  const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED !== 'false';
198
202
  const REQUIRE_BEARER = process.env.MCP_REQUIRE_BEARER === 'true';
@@ -256,7 +260,8 @@ const SOURCE_TO_AGENT_BODY_MAP = new Map([
256
260
  ['cursor', 'cursor'],
257
261
  ['warp', 'warp'],
258
262
  ['openclaw', 'desktop'],
259
- ['hermes', 'desktop']
263
+ ['hermes', 'desktop'],
264
+ ['draft', 'desktop']
260
265
  ]);
261
266
  const DEFAULT_AGENT_BODY = String(process.env.MCP_AGENT_BODY || '')
262
267
  .trim()
@@ -386,7 +391,7 @@ function buildTools() {
386
391
  },
387
392
  {
388
393
  name: 'recall',
389
- description: `Semantic recall from long-term memory (${DB_PROFILE}.marsvault_chunks) using Jina embeddings. Searches across promoted insights, digests, and archived knowledge using vector similarity. Use this to retrieve established patterns, past decisions, documented workflows, or any knowledge that was previously promoted to long-term storage. This is the primary tool for accessing institutional memory.`,
394
+ description: `Semantic recall from long-term memory (${DB_PROFILE}.marsvault_chunks) using Jina embeddings. Returns preview snippets (~${RECALL_PREVIEW_MAX_CHARS} chars) per match by default use get_summary for a longer excerpt or get_full for complete chunk text. Searches promoted insights, digests, and archived knowledge using vector similarity.`,
390
395
  inputSchema: {
391
396
  type: 'object',
392
397
  properties: {
@@ -417,6 +422,30 @@ function buildTools() {
417
422
  additionalProperties: false
418
423
  }
419
424
  },
425
+ {
426
+ name: 'get_summary',
427
+ description: `Fetch a medium-length excerpt (~${CHUNK_SUMMARY_MAX_CHARS} chars) of a long-term memory chunk by ID. Use after recall when a preview match looks relevant. For complete text, call get_full.`,
428
+ inputSchema: {
429
+ type: 'object',
430
+ properties: {
431
+ id: { type: 'string', description: 'UUID of the marsvault_chunks row' }
432
+ },
433
+ required: ['id'],
434
+ additionalProperties: false
435
+ }
436
+ },
437
+ {
438
+ name: 'get_full',
439
+ description: `Fetch the complete text of a long-term memory chunk by ID. Use sparingly after recall/get_summary when you need the full institutional memory entry.`,
440
+ inputSchema: {
441
+ type: 'object',
442
+ properties: {
443
+ id: { type: 'string', description: 'UUID of the marsvault_chunks row' }
444
+ },
445
+ required: ['id'],
446
+ additionalProperties: false
447
+ }
448
+ },
420
449
  {
421
450
  name: 'health_check',
422
451
  description: `Run comprehensive ${PROFILE.displayName} memory system diagnostics. Returns chunk counts by type and visibility, identifies expiring short-term memories that need promotion, detects timeline coverage gaps, and finds conflicting or redundant long-term chunks. Use this regularly to maintain memory hygiene and before batch promotion.`,
@@ -554,6 +583,10 @@ function buildTools() {
554
583
  default: 5,
555
584
  description: 'Maximum recall results per category (identity/workflow/status, default 5)'
556
585
  },
586
+ body: {
587
+ type: 'string',
588
+ description: 'Recipient body name — check for 便條 (handoff notes) addressed to this body and surface them at the top of boot output. Defaults to current profile (e.g. "coco") so any CoCo body receives CoCo-addressed notes without passing an explicit body.'
589
+ },
557
590
  alert_window_hours: {
558
591
  type: 'number',
559
592
  minimum: 1,
@@ -580,7 +613,7 @@ function buildTools() {
580
613
  {
581
614
  name: 'session_close',
582
615
  description:
583
- `Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. Include what was accomplished, what is still pending, and any important context for continuity.`,
616
+ `Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. After saving, automatically promotes up to 5 soon-expiring short-term memories to long-term storage (alert window 48h); promote failures never fail the close. Include what was accomplished, what is still pending, and any important context for continuity.`,
584
617
  inputSchema: {
585
618
  type: 'object',
586
619
  properties: {
@@ -597,6 +630,14 @@ function buildTools() {
597
630
  mood: {
598
631
  type: 'string',
599
632
  description: 'Optional mood marker'
633
+ },
634
+ to: {
635
+ type: 'string',
636
+ description: 'Recipient body name — leave a 便條 (handoff note) for another body, surfaced on that body next session_boot'
637
+ },
638
+ note: {
639
+ type: 'string',
640
+ description: 'Short context handoff for the recipient body (used with `to`)'
600
641
  }
601
642
  },
602
643
  required: ['source', 'summary'],
@@ -1055,6 +1096,8 @@ function listAvailableToolsForAuthMode(authMode) {
1055
1096
  function buildSupabaseCapabilityPayload() {
1056
1097
  return {
1057
1098
  mode: SUPABASE_AUTH_MODE,
1099
+ tool_surface: 'coco',
1100
+ prd_tools_enabled: false,
1058
1101
  anon_allowed_tools: listAvailableToolNamesForAuthMode('anon'),
1059
1102
  effective_tools: listAvailableToolNamesForAuthMode(SUPABASE_AUTH_MODE)
1060
1103
  };
@@ -1382,6 +1425,46 @@ function normalizeOptionalText(value, maxLength = 200) {
1382
1425
  if (!text) return '';
1383
1426
  return text.slice(0, Math.max(1, maxLength));
1384
1427
  }
1428
+
1429
+ function truncateChunkContent(text, maxChars) {
1430
+ const raw = String(text ?? '');
1431
+ if (raw.length <= maxChars) {
1432
+ return { content: raw, preview_truncated: false, content_chars: raw.length };
1433
+ }
1434
+ return {
1435
+ content: raw.slice(0, maxChars),
1436
+ preview_truncated: true,
1437
+ content_chars: raw.length
1438
+ };
1439
+ }
1440
+
1441
+ function applyRecallPreviewToItem(item) {
1442
+ const fullContent = String(item?.content ?? '');
1443
+ const preview = truncateChunkContent(fullContent, RECALL_PREVIEW_MAX_CHARS);
1444
+ return {
1445
+ ...item,
1446
+ content: preview.content,
1447
+ preview_truncated: preview.preview_truncated,
1448
+ content_chars: preview.content_chars
1449
+ };
1450
+ }
1451
+
1452
+ function buildChunkToolFields(chunk) {
1453
+ return {
1454
+ id: chunk.id,
1455
+ source_file: chunk.source_file || null,
1456
+ section: chunk.section || null,
1457
+ body: chunk.body || null,
1458
+ visibility: chunk.visibility || null,
1459
+ tags: chunk.tags ?? [],
1460
+ type: chunk.type || null,
1461
+ date: chunk.date || null,
1462
+ origin: chunk.origin || null,
1463
+ agent_body: chunk.agent_body || null,
1464
+ environment: chunk.environment || null,
1465
+ created_at: chunk.created_at || null
1466
+ };
1467
+ }
1385
1468
  function normalizeOptionalSourceTool(value) {
1386
1469
  const normalized = String(value || '').trim();
1387
1470
  if (!normalized) return '';
@@ -1518,6 +1601,10 @@ function estimateToolUsageTokens(toolName, args = {}) {
1518
1601
  appendTokenCharCount(safeArgs.query, state);
1519
1602
  appendTokenCharCount(safeArgs.type, state);
1520
1603
  break;
1604
+ case 'get_summary':
1605
+ case 'get_full':
1606
+ appendTokenCharCount(safeArgs.id, state);
1607
+ break;
1521
1608
  case 'session_close':
1522
1609
  appendTokenCharCount(safeArgs.summary, state);
1523
1610
  appendTokenCharCount(safeArgs.topics, state);
@@ -2089,6 +2176,64 @@ async function upsertMemoryBySession({
2089
2176
  return fetched || null;
2090
2177
  }
2091
2178
 
2179
+ // ─── 便條 (Body-to-Body handoff notes) ────────────────────────────────────────
2180
+ // Stored as memories rows with recipient_body/note/read_at columns.
2181
+ // session_close(to, note) writes; session_boot(body) reads + marks read.
2182
+ // If the note columns are not yet deployed, these degrade gracefully (caller wraps in try/catch).
2183
+
2184
+ const NOTE_SELECT_COLUMNS =
2185
+ 'id,body,source,session_id,tags,agent_body,environment,created_at,expires_at,recipient_body,note,read_at';
2186
+
2187
+ async function insertNoteMemory({ source, to, note, body, tags, expires_at }) {
2188
+ const resolvedAgentBody = resolveWriteAgentBody({ source });
2189
+ const resolvedEnvironment = resolveWriteEnvironment({});
2190
+ const createdId = crypto.randomUUID();
2191
+ const payload = {
2192
+ id: createdId,
2193
+ body,
2194
+ source,
2195
+ session_id: `note:to:${to}:${createdId.slice(0, 8)}`,
2196
+ tags: normalizeMemoryTagsWithContext(tags, resolvedAgentBody, resolvedEnvironment),
2197
+ agent_body: resolvedAgentBody,
2198
+ environment: resolvedEnvironment,
2199
+ recipient_body: to,
2200
+ note,
2201
+ read_at: null
2202
+ };
2203
+ if (expires_at) payload.expires_at = expires_at;
2204
+ const inserted = await supabaseRequest(`/rest/v1/memories?select=${NOTE_SELECT_COLUMNS}`, {
2205
+ method: 'POST',
2206
+ profile: DB_PROFILE,
2207
+ prefer: 'return=representation',
2208
+ body: [payload]
2209
+ });
2210
+ return Array.isArray(inserted) && inserted.length > 0 ? inserted[0] : null;
2211
+ }
2212
+
2213
+ async function listUnreadNotes(bodyName, limit = 3) {
2214
+ const query = new URLSearchParams();
2215
+ query.set('select', NOTE_SELECT_COLUMNS);
2216
+ query.set('recipient_body', `eq.${bodyName}`);
2217
+ query.set('read_at', 'is.null');
2218
+ query.set('order', 'created_at.desc');
2219
+ query.set('limit', String(Math.min(limit, 10)));
2220
+ const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
2221
+ profile: DB_PROFILE
2222
+ });
2223
+ return Array.isArray(rows) ? rows : [];
2224
+ }
2225
+
2226
+ async function markNoteRead(id) {
2227
+ const query = new URLSearchParams();
2228
+ query.set('id', `eq.${id}`);
2229
+ await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
2230
+ method: 'PATCH',
2231
+ profile: DB_PROFILE,
2232
+ prefer: 'return=minimal',
2233
+ body: { read_at: new Date().toISOString() }
2234
+ });
2235
+ }
2236
+
2092
2237
  async function ensureDailyCloseRecovery(source, nowTs) {
2093
2238
  const yesterdayDate = new Date(toUtcStartOfDay(nowTs).getTime() - 86400000);
2094
2239
  const yesterdayDateKey = formatDateOnly(yesterdayDate);
@@ -2180,6 +2325,7 @@ async function runDailyBoot(args = {}) {
2180
2325
  const source = normalizeDailySource(args.source);
2181
2326
  const topic = normalizeOptionalText(args.topic, 200);
2182
2327
  const mood = normalizeOptionalText(args.mood, 80);
2328
+ const bodyName = normalizeOptionalText(args.body, 60) || DB_PROFILE;
2183
2329
  const queries = buildDailyBootQueries(args);
2184
2330
  const inferredSourceAgentBody = inferAgentBodyFromSource(source);
2185
2331
  const recallScopeArgs = {
@@ -2273,12 +2419,34 @@ async function runDailyBoot(args = {}) {
2273
2419
  }
2274
2420
  : await ensureDailyCloseRecovery(source, nowTs);
2275
2421
 
2422
+ // 便條: surface unread handoff notes addressed to this body, then mark read
2423
+ let notes = [];
2424
+ if (bodyName) {
2425
+ try {
2426
+ const unread = await listUnreadNotes(bodyName);
2427
+ for (const n of unread) {
2428
+ notes.push({
2429
+ id: n.id,
2430
+ from: normalizeOptionalText(n.source, 60) || 'unknown',
2431
+ to: bodyName,
2432
+ note: normalizeOptionalText(n.note, 1000) || '',
2433
+ created_at: n.created_at
2434
+ });
2435
+ await markNoteRead(n.id);
2436
+ }
2437
+ } catch (error) {
2438
+ // note columns may not be deployed yet — degrade gracefully, boot itself still succeeds
2439
+ notes = [];
2440
+ }
2441
+ }
2442
+
2276
2443
  return {
2277
2444
  ok: true,
2278
2445
  profile: DB_PROFILE,
2279
2446
  mode: existingHeartbeat ? 'welcome_back' : 'new_day',
2280
2447
  source,
2281
2448
  date: dateKey,
2449
+ notes,
2282
2450
  heartbeat_id: savedHeartbeat?.id || null,
2283
2451
  agent_name: queries.agent_name,
2284
2452
  last_topic: previousTopic || null,
@@ -2313,6 +2481,8 @@ async function runDailyClose(args = {}) {
2313
2481
  }
2314
2482
  const topics = normalizeDailyTopics(args.topics);
2315
2483
  const mood = normalizeOptionalText(args.mood, 80);
2484
+ const to = normalizeOptionalText(args.to, 60);
2485
+ const note = to ? normalizeOptionalText(args.note, 1000) : null;
2316
2486
  const nowTs = new Date();
2317
2487
  const dateKey = dailyDateKey(nowTs);
2318
2488
  const toolUsageDailySummary = await fetchToolUsageDailySummary(dateKey);
@@ -2344,6 +2514,23 @@ async function runDailyClose(args = {}) {
2344
2514
  expires_at: expiresAt,
2345
2515
  existing: existingClose
2346
2516
  });
2517
+
2518
+ // Auto-promote soon-expiring short-term memories (replaces Hermes dream schedule).
2519
+ // Failure must never break session_close — summary is already stored.
2520
+ let promotedCount = 0;
2521
+ try {
2522
+ const promoteResult = await runBatchPromote({
2523
+ alert_window_hours: 48,
2524
+ max_promote: 5
2525
+ });
2526
+ promotedCount = Math.max(0, Number(promoteResult?.promoted_count) || 0);
2527
+ } catch (error) {
2528
+ console.warn(
2529
+ `[session_close] auto batch_promote failed: ${String(error?.message || error)}`
2530
+ );
2531
+ promotedCount = 0;
2532
+ }
2533
+
2347
2534
  const expirySnapshot = await runHealthExpiryCheck({
2348
2535
  alert_window_hours: 48
2349
2536
  });
@@ -2356,11 +2543,42 @@ async function runDailyClose(args = {}) {
2356
2543
  Number(expirySnapshot?.expiry_alert?.recommended_for_promotion_count) || 0
2357
2544
  );
2358
2545
  const expiryAction =
2359
- recommendPromoteCount > 0
2360
- ? '建議叫 Hermes 或三哥 promote'
2361
- : soonExpiringCount > 0
2362
- ? '有短記憶即將到期,建議先做 health_check 檢視'
2363
- : null;
2546
+ promotedCount > 0
2547
+ ? `auto-promoted ${promotedCount} expiring memor${promotedCount === 1 ? 'y' : 'ies'}`
2548
+ : recommendPromoteCount > 0
2549
+ ? 'expiring memories remain — call batch_promote or wait for next session_close'
2550
+ : soonExpiringCount > 0
2551
+ ? 'short-term memories approaching expiry — review with health_check if needed'
2552
+ : null;
2553
+
2554
+ // 便條: optional handoff note addressed to another body
2555
+ let noteResult = null;
2556
+ if (to) {
2557
+ try {
2558
+ const noteTags = buildLifecycleMemoryTags(['note', `to:${to}`], source, dateKey);
2559
+ const notePayload = {
2560
+ type: 'note',
2561
+ profile: DB_PROFILE,
2562
+ source,
2563
+ to,
2564
+ note,
2565
+ date: dateKey,
2566
+ created_at: nowTs.toISOString()
2567
+ };
2568
+ const savedNote = await insertNoteMemory({
2569
+ source,
2570
+ to,
2571
+ note: note || '',
2572
+ body: JSON.stringify(notePayload),
2573
+ tags: noteTags,
2574
+ expires_at: expiresAt
2575
+ });
2576
+ noteResult = { to, note_id: savedNote?.id || null, delivered: Boolean(savedNote) };
2577
+ } catch (error) {
2578
+ // note columns may not be deployed yet — degrade gracefully, close itself still succeeds
2579
+ noteResult = { to, delivered: false, error: String(error?.message || error) };
2580
+ }
2581
+ }
2364
2582
 
2365
2583
  return {
2366
2584
  ok: true,
@@ -2372,6 +2590,8 @@ async function runDailyClose(args = {}) {
2372
2590
  topics,
2373
2591
  mood: mood || null,
2374
2592
  tool_usage_daily_summary: toolUsageDailySummary,
2593
+ note: noteResult,
2594
+ promoted_count: promotedCount,
2375
2595
  expiry_alert: {
2376
2596
  soon_expiring_count: soonExpiringCount,
2377
2597
  recommend_promote_count: recommendPromoteCount,
@@ -3439,6 +3659,45 @@ function buildPromoteTimeline(chunk, sourceMemory) {
3439
3659
  source_to_promoted_seconds: sourceToPromotedSeconds
3440
3660
  };
3441
3661
  }
3662
+
3663
+ async function runGetSummary(args = {}) {
3664
+ const chunkId = normalizeOptionalUuid(args.id, 'id');
3665
+ if (!chunkId) {
3666
+ throw new Error('id is required');
3667
+ }
3668
+ const chunk = await fetchChunkById(chunkId);
3669
+ if (!chunk) {
3670
+ throw new Error(`memory chunk not found: ${chunkId}`);
3671
+ }
3672
+ const preview = truncateChunkContent(chunk.content, CHUNK_SUMMARY_MAX_CHARS);
3673
+ return {
3674
+ ok: true,
3675
+ ...buildChunkToolFields(chunk),
3676
+ content: preview.content,
3677
+ preview_truncated: preview.preview_truncated,
3678
+ content_chars: preview.content_chars,
3679
+ drill_down: 'Use get_full for complete chunk text.'
3680
+ };
3681
+ }
3682
+
3683
+ async function runGetFull(args = {}) {
3684
+ const chunkId = normalizeOptionalUuid(args.id, 'id');
3685
+ if (!chunkId) {
3686
+ throw new Error('id is required');
3687
+ }
3688
+ const chunk = await fetchChunkById(chunkId);
3689
+ if (!chunk) {
3690
+ throw new Error(`memory chunk not found: ${chunkId}`);
3691
+ }
3692
+ const fullContent = String(chunk.content ?? '');
3693
+ return {
3694
+ ok: true,
3695
+ ...buildChunkToolFields(chunk),
3696
+ content: fullContent,
3697
+ content_chars: fullContent.length
3698
+ };
3699
+ }
3700
+
3442
3701
  async function runExplainMemory(args = {}) {
3443
3702
  const chunkId = normalizeOptionalUuid(args.id, 'id');
3444
3703
  if (!chunkId) {
@@ -3765,9 +4024,15 @@ async function callTool(name, args = {}) {
3765
4024
  }
3766
4025
  }
3767
4026
  : {}),
3768
- items: explained.items
4027
+ items: explained.items.map(applyRecallPreviewToItem)
3769
4028
  };
3770
4029
  }
4030
+ if (toolName === 'get_summary') {
4031
+ return await runGetSummary(args);
4032
+ }
4033
+ if (toolName === 'get_full') {
4034
+ return await runGetFull(args);
4035
+ }
3771
4036
  if (toolName === 'explain_memory') {
3772
4037
  return await runExplainMemory(args);
3773
4038
  }
@@ -4565,6 +4830,25 @@ const server = http.createServer(async (req, res) => {
4565
4830
  return;
4566
4831
  }
4567
4832
 
4833
+ // Streamable HTTP: this server is POST-only (no SSE streaming of
4834
+ // server-initiated notifications). Per MCP spec, respond to GET /mcp with
4835
+ // 405 Method Not Allowed + an `allow` header so compliant clients (Cursor,
4836
+ // Claude Desktop, etc.) fall back to POST-only request/response instead
4837
+ // of treating the missing stream as a fatal transport error (404 would
4838
+ // cause Cursor's V2 FSM to tombstone the transport after 5 retries).
4839
+ if (
4840
+ req.method === 'GET' &&
4841
+ ['/mcp', '/mcp/', '/'].includes(url.pathname)
4842
+ ) {
4843
+ res.writeHead(405, {
4844
+ 'access-control-allow-origin': '*',
4845
+ allow: 'POST',
4846
+ 'cache-control': 'no-store'
4847
+ });
4848
+ res.end();
4849
+ return;
4850
+ }
4851
+
4568
4852
  if (req.method !== 'POST' || !['/mcp', '/mcp/', '/'].includes(url.pathname)) {
4569
4853
  json(res, 404, { error: 'not_found' });
4570
4854
  return;