@marsnme/mcp-gateway 0.2.0 → 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.0",
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",
@@ -49,4 +49,4 @@
49
49
  "engines": {
50
50
  "node": ">=20"
51
51
  }
52
- }
52
+ }
@@ -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()
@@ -307,29 +312,29 @@ function buildTools() {
307
312
  return [
308
313
  {
309
314
  name: 'insert_memory',
310
- description: `Insert a short-term ${PROFILE.displayName} memory into ${DB_PROFILE}.memories (ephemeral context)`,
315
+ description: `Store a short-term ${PROFILE.displayName} memory into ${DB_PROFILE}.memories (ephemeral context that auto-expires). Use this to capture observations, decisions, session notes, or any context worth remembering temporarily. Memories last 7 days by default unless expires_at is set. For permanent knowledge, use memory_ingest instead.`,
311
316
  inputSchema: {
312
317
  type: 'object',
313
318
  properties: {
314
- body: { type: 'string', description: 'Memory content text' },
319
+ body: { type: 'string', description: 'The memory content to store. Be specific and include relevant context — this text is used for semantic search later.' },
315
320
  source: buildMemorySourceSchema(),
316
- session_id: { type: 'string', description: 'Session trace id' },
321
+ session_id: { type: 'string', description: 'Unique identifier for the current session/conversation. Used to group related memories together.' },
317
322
  tags: {
318
323
  type: 'array',
319
324
  items: { type: 'string' },
320
- description: 'Optional tags list'
325
+ description: 'Categorization labels for filtering (e.g. ["decision", "architecture", "bugfix"])'
321
326
  },
322
327
  expires_at: {
323
328
  type: 'string',
324
- description: 'Optional ISO timestamp override'
329
+ description: 'Custom expiration time in ISO 8601 format (e.g. "2026-06-17T00:00:00Z"). Defaults to 7 days from now if omitted.'
325
330
  },
326
331
  agent_body: {
327
332
  type: 'string',
328
- description: 'Optional body label (default inferred from source)'
333
+ description: 'Which persona/agent body this memory belongs to (e.g. "coco", "toto"). Defaults to the profile of this server.'
329
334
  },
330
335
  environment: {
331
336
  type: 'string',
332
- description: 'Optional environment label (default from MCP_ENVIRONMENT)'
337
+ description: 'Deployment environment label (e.g. "production", "staging"). Defaults to MCP_ENVIRONMENT if set.'
333
338
  }
334
339
  },
335
340
  required: ['body', 'source', 'session_id'],
@@ -338,36 +343,36 @@ function buildTools() {
338
343
  },
339
344
  {
340
345
  name: 'list_memories',
341
- description: `List recent memories from ${DB_PROFILE}.memories`,
346
+ description: `List recent short-term memories from ${DB_PROFILE}.memories in reverse chronological order — like a daily log or activity feed. Use this to review what was recently captured, check for duplicate memories before inserting, or browse recent activity. This is a time-ordered listing tool, not a search tool. For semantic search by meaning, use search_memories instead. For long-term knowledge retrieval, use recall. Returns memory body, source, creation time, and expiration status.`,
342
347
  inputSchema: {
343
348
  type: 'object',
344
349
  properties: {
345
- limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
350
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Maximum number of memories to return (default 20)' },
346
351
  source: buildMemorySourceSchema(),
347
- unexpired_only: { type: 'boolean', default: true }
352
+ unexpired_only: { type: 'boolean', default: true, description: 'When true (default), only return memories that have not yet expired. Set false to include expired entries.' }
348
353
  },
349
354
  additionalProperties: false
350
355
  }
351
356
  },
352
357
  {
353
358
  name: 'search_memories',
354
- description: `Semantic search memories from ${DB_PROFILE}.memories using Jina embeddings`,
359
+ description: `Semantic search across short-term memories in ${DB_PROFILE}.memories using Jina embeddings. Finds memories by meaning, not just keywords — ask a natural language question and get the most relevant matches. Use this to recall past decisions, find related context, or check if something was already discussed recently.`,
355
360
  inputSchema: {
356
361
  type: 'object',
357
362
  properties: {
358
- query: { type: 'string', description: 'Semantic search query text' },
359
- limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
363
+ query: { type: 'string', description: 'Natural language search query. Describe what you are looking for — semantic matching finds relevant results even without exact keywords.' },
364
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Maximum number of results to return' },
360
365
  source: buildMemorySourceSchema(),
361
- unexpired_only: { type: 'boolean', default: true },
362
- min_similarity: { type: 'number', minimum: -1, maximum: 1 },
363
- scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body' },
366
+ unexpired_only: { type: 'boolean', default: true, description: 'When true (default), exclude expired memories from results' },
367
+ min_similarity: { type: 'number', minimum: -1, maximum: 1, description: 'Minimum cosine similarity threshold for results (range -1 to 1). Higher values return fewer but more relevant matches.' },
368
+ scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body', description: 'Search scope: "this_body" searches only the current profile, "all_bodies" searches across all personas.' },
364
369
  agent_body: {
365
370
  type: 'string',
366
- description: 'Optional body scope key; defaults to source/body/env inference'
371
+ description: 'Filter results to a specific persona/body (e.g. "coco", "toto")'
367
372
  },
368
373
  environment: {
369
374
  type: 'string',
370
- description: 'Optional environment filter'
375
+ description: 'Filter results to a specific environment (e.g. "production", "staging")'
371
376
  }
372
377
  },
373
378
  required: ['query'],
@@ -377,7 +382,7 @@ function buildTools() {
377
382
  {
378
383
  name: 'reload_source_registry',
379
384
  description:
380
- `Reload enabled sources cache from ${DB_PROFILE}.${SOURCE_REGISTRY_TABLE} (effective when MCP_SOURCE_MODE=registry)`,
385
+ `Reload the source registry cache from ${DB_PROFILE}.${SOURCE_REGISTRY_TABLE}. Use this after adding or removing entries to refresh which sources are enabled for insert/search filtering. Only effective when running in registry mode (MCP_SOURCE_MODE=registry).`,
381
386
  inputSchema: {
382
387
  type: 'object',
383
388
  properties: {},
@@ -386,40 +391,64 @@ function buildTools() {
386
391
  },
387
392
  {
388
393
  name: 'recall',
389
- description: `Semantic recall from ${DB_PROFILE}.marsvault_chunks using Jina embeddings`,
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: {
393
- query: { type: 'string', description: 'Semantic recall query text' },
394
- limit: { type: 'number', minimum: 1, maximum: 50, default: 5 },
395
- body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE },
396
- include_global: { type: 'boolean', default: true },
397
- include_shared: { type: 'boolean', default: true },
398
- include_private: { type: 'boolean', default: true },
399
- type: { type: 'string', description: 'Optional chunk type filter' },
400
- min_similarity: { type: 'number', minimum: -1, maximum: 1 },
401
- scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body' },
398
+ query: { type: 'string', description: 'Natural language query describing what knowledge you need. The system finds semantically similar chunks — describe the concept, not just keywords.' },
399
+ limit: { type: 'number', minimum: 1, maximum: 50, default: 5, description: 'Maximum number of chunks to return (default 5)' },
400
+ body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE, description: 'Which persona profile to search in (e.g. "coco", "toto", "system")' },
401
+ include_global: { type: 'boolean', default: true, description: 'Include globally visible chunks in results' },
402
+ include_shared: { type: 'boolean', default: true, description: 'Include shared-visibility chunks in results' },
403
+ include_private: { type: 'boolean', default: true, description: 'Include private chunks in results' },
404
+ type: { type: 'string', description: 'Filter by chunk type (e.g. "insight", "digest", "observation")' },
405
+ min_similarity: { type: 'number', minimum: -1, maximum: 1, description: 'Minimum cosine similarity threshold. Raise for higher precision, lower for broader recall.' },
406
+ scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body', description: 'Search scope: "this_body" for current profile only, "all_bodies" for cross-persona search' },
402
407
  agent_body: {
403
408
  type: 'string',
404
- description: 'Optional body scope key; defaults to MCP_AGENT_BODY when present'
409
+ description: 'Filter to a specific persona/body scope'
405
410
  },
406
411
  environment: {
407
412
  type: 'string',
408
- description: 'Optional environment filter'
413
+ description: 'Filter to a specific environment label'
409
414
  },
410
415
  debug_explain: {
411
416
  type: 'boolean',
412
417
  default: false,
413
- description: 'Include query/hit token overlap debug details'
418
+ description: 'When true, include token overlap details in each result for debugging relevance'
414
419
  }
415
420
  },
416
421
  required: ['query'],
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
- description: `Run ${PROFILE.displayName} memory health diagnostics (count_chunks, expiry_alert, coverage_map, detect_conflicts)`,
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.`,
423
452
  inputSchema: {
424
453
  type: 'object',
425
454
  properties: {
@@ -514,11 +543,11 @@ function buildTools() {
514
543
  {
515
544
  name: 'session_boot',
516
545
  description:
517
- 'One-call session boot rhythm: always run identity/workflow/status recall + expiry-focused health snapshot + heartbeat sign-in',
546
+ `Initialize a new ${PROFILE.displayName} session by loading identity, workflow context, and recent status from long-term memory. Also runs an expiry-focused health snapshot and records a heartbeat sign-in. Call this once at the start of every new conversation to restore continuity and awareness of pending work.`,
518
547
  inputSchema: {
519
548
  type: 'object',
520
549
  properties: {
521
- source: { type: 'string', enum: PROFILE.sourceWhitelist },
550
+ source: { type: 'string', enum: PROFILE.sourceWhitelist, description: 'Source tool identifier (e.g. "warp", "cursor", "perplexity") for provenance tracking' },
522
551
  body_name: {
523
552
  type: 'string',
524
553
  description: 'Optional body/persona name (例如:大家姐、三哥、五妹、Toto)'
@@ -551,7 +580,12 @@ function buildTools() {
551
580
  type: 'number',
552
581
  minimum: 1,
553
582
  maximum: 10,
554
- default: 5
583
+ default: 5,
584
+ description: 'Maximum recall results per category (identity/workflow/status, default 5)'
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.'
555
589
  },
556
590
  alert_window_hours: {
557
591
  type: 'number',
@@ -579,7 +613,7 @@ function buildTools() {
579
613
  {
580
614
  name: 'session_close',
581
615
  description:
582
- 'Store session close summary with 7-day retention for CoCo/Toto rhythm closure',
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.`,
583
617
  inputSchema: {
584
618
  type: 'object',
585
619
  properties: {
@@ -596,6 +630,14 @@ function buildTools() {
596
630
  mood: {
597
631
  type: 'string',
598
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`)'
599
641
  }
600
642
  },
601
643
  required: ['source', 'summary'],
@@ -604,24 +646,24 @@ function buildTools() {
604
646
  },
605
647
  {
606
648
  name: 'dream_ingest',
607
- description: `Chunk and ingest Hermes digest text into ${DB_PROFILE}.marsvault_chunks as long-term insight data`,
649
+ description: `Ingest a Hermes digest (periodic summary report) into ${DB_PROFILE}.marsvault_chunks long-term memory. The content is automatically chunked into semantically meaningful segments and stored with vector embeddings for future recall. Use this for scheduled reports, daily digests, or any structured summary content that should be permanently available.`,
608
650
  inputSchema: {
609
651
  type: 'object',
610
652
  properties: {
611
- content: { type: 'string', description: 'Digest full text content' },
612
- source_file: { type: 'string', description: 'Logical source path for digest' },
613
- section: { type: 'string', description: 'Section label prefix' },
653
+ content: { type: 'string', description: 'The full text content of the digest to ingest. Will be automatically split into semantically coherent chunks.' },
654
+ source_file: { type: 'string', description: 'Logical file path or identifier for the source of this digest (e.g. "hermes/daily/2026-06-10")' },
655
+ section: { type: 'string', description: 'Optional section label prefix to tag all resulting chunks' },
614
656
  tags: {
615
657
  type: 'array',
616
658
  items: { type: 'string' },
617
- description: 'Optional tags list'
659
+ description: 'Tags for categorization (e.g. ["digest", "daily", "report"])'
618
660
  },
619
- type: { type: 'string', description: 'Chunk type', default: 'digest' },
620
- date: { type: 'string', description: 'Optional YYYY-MM-DD date override' },
621
- body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE },
622
- visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private' },
623
- origin: { type: 'string', description: 'Origin marker', default: PROFILE.digestDefaultOrigin },
624
- max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200 }
661
+ type: { type: 'string', description: 'Chunk type label', default: 'digest' },
662
+ date: { type: 'string', description: 'Date for this digest in YYYY-MM-DD format. Defaults to today if omitted.' },
663
+ body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE, description: 'Target persona profile for storage' },
664
+ visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private', description: 'Access level: "private" = owner only, "shared" = cross-profile, "global" = system-wide' },
665
+ origin: { type: 'string', description: 'Origin marker identifying where this content came from', default: PROFILE.digestDefaultOrigin },
666
+ max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200, description: 'Maximum character length per chunk (default 1200). Larger values = fewer, longer chunks.' }
625
667
  },
626
668
  required: ['content'],
627
669
  additionalProperties: false
@@ -629,48 +671,48 @@ function buildTools() {
629
671
  },
630
672
  {
631
673
  name: PROFILE.memoryIngestToolName,
632
- description: `Chunk and ingest ${PROFILE.displayName} long-term insight content into ${DB_PROFILE}.marsvault_chunks (not short-memory)`,
674
+ description: `Promote content into permanent long-term memory (${DB_PROFILE}.marsvault_chunks). Automatically chunks the input text, generates vector embeddings, and stores each segment for semantic recall. Use this to preserve important insights, decisions, patterns, or knowledge that should survive beyond the current session. This is the primary path from ephemeral to permanent memory.`,
633
675
  inputSchema: {
634
676
  type: 'object',
635
677
  properties: {
636
- content: { type: 'string', description: 'Insight full text content' },
637
- source_file: { type: 'string', description: 'Logical source path for insight content' },
638
- section: { type: 'string', description: 'Section label prefix' },
678
+ content: { type: 'string', description: 'The insight content to promote to long-term memory. Be specific and self-contained — future recall depends on the quality of this text.' },
679
+ source_file: { type: 'string', description: 'Logical file path or identifier for the source (e.g. "sessions/2026-06-10-session-notes")' },
680
+ section: { type: 'string', description: 'Optional section label prefix to organize chunks within the source' },
639
681
  tags: {
640
682
  type: 'array',
641
683
  items: { type: 'string' },
642
- description: 'Optional tags list'
684
+ description: 'Categorization tags (e.g. ["decision", "architecture"])'
643
685
  },
644
- type: { type: 'string', description: 'Chunk type', default: 'insight' },
645
- date: { type: 'string', description: 'Optional YYYY-MM-DD date override' },
646
- visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private' },
686
+ type: { type: 'string', description: 'Content type label (e.g. "insight", "observation", "decision")', default: 'insight' },
687
+ date: { type: 'string', description: 'Date for this content in YYYY-MM-DD format. Defaults to today if omitted.' },
688
+ visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private', description: 'Access level: "private" = this profile only, "shared" = cross-profile readable, "global" = system-wide' },
647
689
  origin: buildMemoryIngestOriginSchema(),
648
690
  source_memory_id: {
649
691
  type: 'string',
650
- description: 'Optional short-memory id to link promoted long-memory chunks'
692
+ description: 'Link this promotion to a specific short-term memory ID (for traceability)'
651
693
  },
652
694
  source_session_id: {
653
695
  type: 'string',
654
- description: 'Optional source session id for provenance'
696
+ description: 'Session ID where this insight originated (for provenance tracking)'
655
697
  },
656
698
  source_tool: {
657
699
  type: 'string',
658
700
  enum: PROFILE.sourceWhitelist,
659
- description: 'Optional source tool for provenance'
701
+ description: 'The tool/platform where this insight was originally captured'
660
702
  },
661
703
  source_user_note: {
662
704
  type: 'string',
663
- description: 'Optional user note describing why this memory was promoted'
705
+ description: 'Brief note explaining why this memory was selected for promotion'
664
706
  },
665
707
  agent_body: {
666
708
  type: 'string',
667
- description: 'Optional body label for memory boundary'
709
+ description: 'The persona/body this memory belongs to (e.g. "coco", "toto")'
668
710
  },
669
711
  environment: {
670
712
  type: 'string',
671
- description: 'Optional environment label'
713
+ description: 'Environment label (e.g. "production", "staging")'
672
714
  },
673
- max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200 }
715
+ max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200, description: 'Maximum characters per chunk (default 1200). Adjust for finer or coarser granularity.' }
674
716
  },
675
717
  required: ['content'],
676
718
  additionalProperties: false
@@ -678,19 +720,19 @@ function buildTools() {
678
720
  },
679
721
  {
680
722
  name: 'demote_memory',
681
- description: `Mark a long-memory chunk as deprecated/superseded in ${DB_PROFILE}.marsvault_chunks`,
723
+ description: `Deprecate a long-term memory chunk in ${DB_PROFILE}.marsvault_chunks, marking it as outdated or superseded. The chunk is not deleted — it is flagged so it no longer appears in recall results. Use this when information becomes incorrect, irrelevant, or is replaced by newer knowledge. Optionally link to the replacement chunk for traceability.`,
682
724
  inputSchema: {
683
725
  type: 'object',
684
726
  properties: {
685
- id: { type: 'string', description: 'Long-memory chunk id (UUID)' },
686
- deprecated_reason: { type: 'string', description: 'Reason for deprecation' },
727
+ id: { type: 'string', description: 'UUID of the long-term memory chunk to deprecate' },
728
+ deprecated_reason: { type: 'string', description: 'Why this memory is being deprecated (e.g. "superseded by newer architecture decision", "information confirmed incorrect")' },
687
729
  superseded_by: {
688
730
  type: 'string',
689
- description: 'Optional replacement chunk id (UUID) when this memory is superseded'
731
+ description: 'UUID of the replacement chunk, if this memory is being superseded by updated content'
690
732
  },
691
733
  deprecated_at: {
692
734
  type: 'string',
693
- description: 'Optional ISO timestamp override; defaults to now'
735
+ description: 'Custom deprecation timestamp in ISO 8601 format. Defaults to now if omitted.'
694
736
  }
695
737
  },
696
738
  required: ['id', 'deprecated_reason'],
@@ -699,7 +741,7 @@ function buildTools() {
699
741
  },
700
742
  {
701
743
  name: 'soft_forget',
702
- description: `Expire short-memory rows early in ${DB_PROFILE}.memories (manual cleanup without hard delete)`,
744
+ description: `Expire short-term memories in ${DB_PROFILE}.memories early without permanently deleting them. Marked memories become invisible to search and list operations but remain in the database for audit purposes. Use this to clean up irrelevant or incorrect short-term memories before their natural expiration.`,
703
745
  inputSchema: {
704
746
  type: 'object',
705
747
  properties: {
@@ -708,15 +750,15 @@ function buildTools() {
708
750
  items: { type: 'string' },
709
751
  minItems: 1,
710
752
  maxItems: 50,
711
- description: 'Short-memory IDs (UUID) to expire immediately'
753
+ description: 'Array of short-term memory UUIDs to expire. Up to 50 at a time.'
712
754
  },
713
755
  reason: {
714
756
  type: 'string',
715
- description: 'Optional note for why these memories are being soft-forgotten'
757
+ description: 'Brief explanation of why these memories are being forgotten (for audit trail)'
716
758
  },
717
759
  forgotten_at: {
718
760
  type: 'string',
719
- description: 'Optional ISO timestamp override; defaults to now'
761
+ description: 'Custom expiration timestamp in ISO 8601 format. Defaults to now if omitted.'
720
762
  }
721
763
  },
722
764
  required: ['ids'],
@@ -725,11 +767,11 @@ function buildTools() {
725
767
  },
726
768
  {
727
769
  name: 'explain_memory',
728
- description: `Explain provenance of a long-memory chunk in ${DB_PROFILE}.marsvault_chunks (source memory/session/tool/timeline)`,
770
+ description: `Trace the full provenance of a long-term memory chunk in ${DB_PROFILE}.marsvault_chunks — where it came from, how it was created, and what source material it was derived from. Returns the original source memory, session, tool, and timeline information. Use this to understand why a piece of knowledge exists or to verify its reliability.`,
729
771
  inputSchema: {
730
772
  type: 'object',
731
773
  properties: {
732
- id: { type: 'string', description: 'Long-memory chunk id (UUID)' }
774
+ id: { type: 'string', description: 'UUID of the long-term memory chunk to explain' }
733
775
  },
734
776
  required: ['id'],
735
777
  additionalProperties: false
@@ -737,7 +779,7 @@ function buildTools() {
737
779
  },
738
780
  {
739
781
  name: 'batch_promote',
740
- description: `Auto-promote expiring short memories to long-term ${DB_PROFILE}.marsvault_chunks. Finds candidates via health check scoring, ingests content, marks promoted.`,
782
+ description: `Automatically promote expiring short-term memories from ${DB_PROFILE}.memories to permanent long-term storage (${DB_PROFILE}.marsvault_chunks). Scans for memories that will expire within the alert window, then chunks and ingests each one with vector embeddings. Use this to prevent valuable context from being lost when short-term memories expire. Supports dry-run mode to preview candidates before committing.`,
741
783
  inputSchema: {
742
784
  type: 'object',
743
785
  properties: {
@@ -746,29 +788,29 @@ function buildTools() {
746
788
  minimum: 1,
747
789
  maximum: 720,
748
790
  default: 48,
749
- description: 'Look-ahead window for expiring memories (hours)'
791
+ description: 'Look-ahead window in hours. Memories expiring within this window are candidates for promotion (default 48).'
750
792
  },
751
793
  max_promote: {
752
794
  type: 'number',
753
795
  minimum: 1,
754
796
  maximum: 50,
755
797
  default: 10,
756
- description: 'Maximum memories to promote in one batch'
798
+ description: 'Maximum number of memories to promote in a single batch (default 10).'
757
799
  },
758
800
  dry_run: {
759
801
  type: 'boolean',
760
802
  default: false,
761
- description: 'If true, list candidates without promoting'
803
+ description: 'When true, list candidate memories without actually promoting them. Use to preview before committing.'
762
804
  },
763
805
  memory_ids: {
764
806
  type: 'array',
765
807
  items: { type: 'string' },
766
808
  maxItems: 50,
767
- description: 'Optional explicit memory IDs to promote (skips auto-detection)'
809
+ description: 'Explicit list of short-term memory UUIDs to promote. When provided, auto-detection is skipped and only these IDs are processed.'
768
810
  },
769
811
  origin: {
770
812
  type: 'string',
771
- description: 'Origin marker for promoted chunks',
813
+ description: 'Origin marker tagged on all promoted chunks for traceability',
772
814
  default: 'batch-promote'
773
815
  }
774
816
  },
@@ -1054,6 +1096,8 @@ function listAvailableToolsForAuthMode(authMode) {
1054
1096
  function buildSupabaseCapabilityPayload() {
1055
1097
  return {
1056
1098
  mode: SUPABASE_AUTH_MODE,
1099
+ tool_surface: 'coco',
1100
+ prd_tools_enabled: false,
1057
1101
  anon_allowed_tools: listAvailableToolNamesForAuthMode('anon'),
1058
1102
  effective_tools: listAvailableToolNamesForAuthMode(SUPABASE_AUTH_MODE)
1059
1103
  };
@@ -1381,6 +1425,46 @@ function normalizeOptionalText(value, maxLength = 200) {
1381
1425
  if (!text) return '';
1382
1426
  return text.slice(0, Math.max(1, maxLength));
1383
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
+ }
1384
1468
  function normalizeOptionalSourceTool(value) {
1385
1469
  const normalized = String(value || '').trim();
1386
1470
  if (!normalized) return '';
@@ -1517,6 +1601,10 @@ function estimateToolUsageTokens(toolName, args = {}) {
1517
1601
  appendTokenCharCount(safeArgs.query, state);
1518
1602
  appendTokenCharCount(safeArgs.type, state);
1519
1603
  break;
1604
+ case 'get_summary':
1605
+ case 'get_full':
1606
+ appendTokenCharCount(safeArgs.id, state);
1607
+ break;
1520
1608
  case 'session_close':
1521
1609
  appendTokenCharCount(safeArgs.summary, state);
1522
1610
  appendTokenCharCount(safeArgs.topics, state);
@@ -2088,6 +2176,64 @@ async function upsertMemoryBySession({
2088
2176
  return fetched || null;
2089
2177
  }
2090
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
+
2091
2237
  async function ensureDailyCloseRecovery(source, nowTs) {
2092
2238
  const yesterdayDate = new Date(toUtcStartOfDay(nowTs).getTime() - 86400000);
2093
2239
  const yesterdayDateKey = formatDateOnly(yesterdayDate);
@@ -2179,6 +2325,7 @@ async function runDailyBoot(args = {}) {
2179
2325
  const source = normalizeDailySource(args.source);
2180
2326
  const topic = normalizeOptionalText(args.topic, 200);
2181
2327
  const mood = normalizeOptionalText(args.mood, 80);
2328
+ const bodyName = normalizeOptionalText(args.body, 60) || DB_PROFILE;
2182
2329
  const queries = buildDailyBootQueries(args);
2183
2330
  const inferredSourceAgentBody = inferAgentBodyFromSource(source);
2184
2331
  const recallScopeArgs = {
@@ -2272,12 +2419,34 @@ async function runDailyBoot(args = {}) {
2272
2419
  }
2273
2420
  : await ensureDailyCloseRecovery(source, nowTs);
2274
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
+
2275
2443
  return {
2276
2444
  ok: true,
2277
2445
  profile: DB_PROFILE,
2278
2446
  mode: existingHeartbeat ? 'welcome_back' : 'new_day',
2279
2447
  source,
2280
2448
  date: dateKey,
2449
+ notes,
2281
2450
  heartbeat_id: savedHeartbeat?.id || null,
2282
2451
  agent_name: queries.agent_name,
2283
2452
  last_topic: previousTopic || null,
@@ -2312,6 +2481,8 @@ async function runDailyClose(args = {}) {
2312
2481
  }
2313
2482
  const topics = normalizeDailyTopics(args.topics);
2314
2483
  const mood = normalizeOptionalText(args.mood, 80);
2484
+ const to = normalizeOptionalText(args.to, 60);
2485
+ const note = to ? normalizeOptionalText(args.note, 1000) : null;
2315
2486
  const nowTs = new Date();
2316
2487
  const dateKey = dailyDateKey(nowTs);
2317
2488
  const toolUsageDailySummary = await fetchToolUsageDailySummary(dateKey);
@@ -2343,6 +2514,23 @@ async function runDailyClose(args = {}) {
2343
2514
  expires_at: expiresAt,
2344
2515
  existing: existingClose
2345
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
+
2346
2534
  const expirySnapshot = await runHealthExpiryCheck({
2347
2535
  alert_window_hours: 48
2348
2536
  });
@@ -2355,11 +2543,42 @@ async function runDailyClose(args = {}) {
2355
2543
  Number(expirySnapshot?.expiry_alert?.recommended_for_promotion_count) || 0
2356
2544
  );
2357
2545
  const expiryAction =
2358
- recommendPromoteCount > 0
2359
- ? '建議叫 Hermes 或三哥 promote'
2360
- : soonExpiringCount > 0
2361
- ? '有短記憶即將到期,建議先做 health_check 檢視'
2362
- : 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
+ }
2363
2582
 
2364
2583
  return {
2365
2584
  ok: true,
@@ -2371,6 +2590,8 @@ async function runDailyClose(args = {}) {
2371
2590
  topics,
2372
2591
  mood: mood || null,
2373
2592
  tool_usage_daily_summary: toolUsageDailySummary,
2593
+ note: noteResult,
2594
+ promoted_count: promotedCount,
2374
2595
  expiry_alert: {
2375
2596
  soon_expiring_count: soonExpiringCount,
2376
2597
  recommend_promote_count: recommendPromoteCount,
@@ -3438,6 +3659,45 @@ function buildPromoteTimeline(chunk, sourceMemory) {
3438
3659
  source_to_promoted_seconds: sourceToPromotedSeconds
3439
3660
  };
3440
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
+
3441
3701
  async function runExplainMemory(args = {}) {
3442
3702
  const chunkId = normalizeOptionalUuid(args.id, 'id');
3443
3703
  if (!chunkId) {
@@ -3764,9 +4024,15 @@ async function callTool(name, args = {}) {
3764
4024
  }
3765
4025
  }
3766
4026
  : {}),
3767
- items: explained.items
4027
+ items: explained.items.map(applyRecallPreviewToItem)
3768
4028
  };
3769
4029
  }
4030
+ if (toolName === 'get_summary') {
4031
+ return await runGetSummary(args);
4032
+ }
4033
+ if (toolName === 'get_full') {
4034
+ return await runGetFull(args);
4035
+ }
3770
4036
  if (toolName === 'explain_memory') {
3771
4037
  return await runExplainMemory(args);
3772
4038
  }
@@ -4564,6 +4830,25 @@ const server = http.createServer(async (req, res) => {
4564
4830
  return;
4565
4831
  }
4566
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
+
4567
4852
  if (req.method !== 'POST' || !['/mcp', '/mcp/', '/'].includes(url.pathname)) {
4568
4853
  json(res, 404, { error: 'not_found' });
4569
4854
  return;