@marsnme/mcp-gateway 0.2.1 → 0.3.1

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.1",
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
+ }
@@ -0,0 +1,74 @@
1
+ // MARS-422: bearer bypass guard (RED first — see scripts/tests/test_auth_guard.mjs)
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { shouldRequireBearer } from '../../lib/auth-guard.mjs';
5
+
6
+ const base = { requireBearer: true, bypassForPrivate: true };
7
+
8
+ test('public request via Cloudflare edge must require bearer even with private X-Forwarded-Host', () => {
9
+ const headers = {
10
+ 'cf-ray': '8b1f9c2d4e5f-HKG',
11
+ 'cf-connecting-ip': '203.0.113.7',
12
+ 'x-forwarded-host': '127.0.0.1',
13
+ host: 'coco-mcp.marsgroup.asia',
14
+ };
15
+ assert.equal(
16
+ shouldRequireBearer({ ...base, headers, remoteAddress: '127.0.0.1' }),
17
+ true,
18
+ 'spoofed X-Forwarded-Host from the internet must NOT bypass auth'
19
+ );
20
+ });
21
+
22
+ test('edge request from a LAN socket peer still requires bearer', () => {
23
+ const headers = { 'cf-ray': 'x', 'x-forwarded-host': '192.168.100.102' };
24
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '192.168.100.102' }), true);
25
+ });
26
+
27
+ test('request with cf-connecting-ip only (no cf-ray) is also treated as edge', () => {
28
+ const headers = { 'cf-connecting-ip': '203.0.113.7', 'x-forwarded-host': '10.0.0.5' };
29
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '127.0.0.1' }), true);
30
+ });
31
+
32
+ test('loopback caller without edge headers keeps the private bypass', () => {
33
+ assert.equal(
34
+ shouldRequireBearer({ ...base, headers: { host: '127.0.0.1:18790' }, remoteAddress: '127.0.0.1' }),
35
+ false
36
+ );
37
+ });
38
+
39
+ test('LAN caller without edge headers keeps the private bypass (SSH tunnel case)', () => {
40
+ // Pi's coco-memory-local reaches the CT as 192.168.100.x over ssh -L.
41
+ const headers = { host: '192.168.100.102:18790', 'x-forwarded-host': '127.0.0.1' };
42
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '192.168.100.102' }), false);
43
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '10.4.5.6' }), false);
44
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '172.20.1.9' }), false);
45
+ });
46
+
47
+ test('public peer without edge headers must NOT bypass', () => {
48
+ const headers = { 'x-forwarded-host': '127.0.0.1', host: '127.0.0.1:18790' };
49
+ assert.equal(
50
+ shouldRequireBearer({ ...base, headers, remoteAddress: '203.0.113.7' }),
51
+ true,
52
+ 'a public client reaching the port directly must not be trusted'
53
+ );
54
+ });
55
+
56
+ test('requireBearer=false disables auth entirely (unchanged behaviour)', () => {
57
+ const headers = { 'cf-ray': 'x', 'x-forwarded-host': '127.0.0.1' };
58
+ assert.equal(
59
+ shouldRequireBearer({ requireBearer: false, bypassForPrivate: true, headers, remoteAddress: '127.0.0.1' }),
60
+ false
61
+ );
62
+ });
63
+
64
+ test('bypassForPrivate=false always requires bearer', () => {
65
+ assert.equal(
66
+ shouldRequireBearer({
67
+ requireBearer: true,
68
+ bypassForPrivate: false,
69
+ headers: { host: '127.0.0.1:18790' },
70
+ remoteAddress: '127.0.0.1',
71
+ }),
72
+ true
73
+ );
74
+ });
@@ -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
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { dirname } from 'node:path';
8
+ import { shouldRequireBearer as guardShouldRequireBearer } from './lib/auth-guard.mjs';
8
9
 
9
10
  const PROFILE_CONFIGS = {
10
11
  coco: {
@@ -13,7 +14,7 @@ const PROFILE_CONFIGS = {
13
14
  defaultPort: 18790,
14
15
  gatewayDir: 'coco-mcp-gateway',
15
16
  publicHostSuffix: 'coco-mcp.marsgroup.asia',
16
- sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes'],
17
+ sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes', 'draft', 'grok', 'omp'],
17
18
  recallBodyEnum: ['coco', 'toto', 'system'],
18
19
  digestDefaultOrigin: 'hermes-coco-digest',
19
20
  memoryIngestToolName: 'memory_ingest',
@@ -24,6 +25,7 @@ const PROFILE_CONFIGS = {
24
25
  'perplexity-coco',
25
26
  'cursor-coco',
26
27
  'warp-coco',
28
+ 'omp-coco',
27
29
  'leo-manual',
28
30
  'hermes-coco-digest'
29
31
  ],
@@ -35,7 +37,7 @@ const PROFILE_CONFIGS = {
35
37
  defaultPort: 18791,
36
38
  gatewayDir: 'toto-mcp-gateway',
37
39
  publicHostSuffix: 'toto-mcp.marsgroup.asia',
38
- sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw'],
40
+ sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'grok'],
39
41
  recallBodyEnum: ['toto', 'system'],
40
42
  digestDefaultOrigin: 'hermes-toto-digest',
41
43
  memoryIngestToolName: 'memory_ingest',
@@ -190,9 +192,13 @@ const ANON_ALLOWED_TOOL_NAMES = new Set([
190
192
  'search_memories',
191
193
  'reload_source_registry',
192
194
  'recall',
195
+ 'get_summary',
196
+ 'get_full',
193
197
  'health_check',
194
198
  'explain_memory'
195
199
  ]);
200
+ const RECALL_PREVIEW_MAX_CHARS = 80;
201
+ const CHUNK_SUMMARY_MAX_CHARS = 300;
196
202
  const SUPABASE_REQUEST_CONTEXT = new AsyncLocalStorage();
197
203
  const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED !== 'false';
198
204
  const REQUIRE_BEARER = process.env.MCP_REQUIRE_BEARER === 'true';
@@ -256,7 +262,9 @@ const SOURCE_TO_AGENT_BODY_MAP = new Map([
256
262
  ['cursor', 'cursor'],
257
263
  ['warp', 'warp'],
258
264
  ['openclaw', 'desktop'],
259
- ['hermes', 'desktop']
265
+ ['hermes', 'desktop'],
266
+ ['draft', 'desktop'],
267
+ ['omp', 'omp']
260
268
  ]);
261
269
  const DEFAULT_AGENT_BODY = String(process.env.MCP_AGENT_BODY || '')
262
270
  .trim()
@@ -386,7 +394,7 @@ function buildTools() {
386
394
  },
387
395
  {
388
396
  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.`,
397
+ 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
398
  inputSchema: {
391
399
  type: 'object',
392
400
  properties: {
@@ -417,6 +425,30 @@ function buildTools() {
417
425
  additionalProperties: false
418
426
  }
419
427
  },
428
+ {
429
+ name: 'get_summary',
430
+ 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.`,
431
+ inputSchema: {
432
+ type: 'object',
433
+ properties: {
434
+ id: { type: 'string', description: 'UUID of the marsvault_chunks row' }
435
+ },
436
+ required: ['id'],
437
+ additionalProperties: false
438
+ }
439
+ },
440
+ {
441
+ name: 'get_full',
442
+ 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.`,
443
+ inputSchema: {
444
+ type: 'object',
445
+ properties: {
446
+ id: { type: 'string', description: 'UUID of the marsvault_chunks row' }
447
+ },
448
+ required: ['id'],
449
+ additionalProperties: false
450
+ }
451
+ },
420
452
  {
421
453
  name: 'health_check',
422
454
  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 +586,10 @@ function buildTools() {
554
586
  default: 5,
555
587
  description: 'Maximum recall results per category (identity/workflow/status, default 5)'
556
588
  },
589
+ body: {
590
+ type: 'string',
591
+ 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.'
592
+ },
557
593
  alert_window_hours: {
558
594
  type: 'number',
559
595
  minimum: 1,
@@ -580,7 +616,7 @@ function buildTools() {
580
616
  {
581
617
  name: 'session_close',
582
618
  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.`,
619
+ `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. Also auto-posts a session-close broadcast to the CoCo family board (board_posts) so other bodies see what was accomplished/pending on next session_boot; board post failures never fail the close. Include what was accomplished, what is still pending, and any important context for continuity.`,
584
620
  inputSchema: {
585
621
  type: 'object',
586
622
  properties: {
@@ -597,6 +633,34 @@ function buildTools() {
597
633
  mood: {
598
634
  type: 'string',
599
635
  description: 'Optional mood marker'
636
+ },
637
+ to: {
638
+ type: 'string',
639
+ description: 'Recipient body name — leave a 便條 (handoff note) for another body, surfaced on that body next session_boot'
640
+ },
641
+ note: {
642
+ type: 'string',
643
+ description: 'Short context handoff for the recipient body (used with `to`)'
644
+ },
645
+ body_name: {
646
+ type: 'string',
647
+ description: 'Optional posting body name for the auto board_post (e.g. "四哥", "三哥"). Defaults to profile name.'
648
+ },
649
+ auto_board_post: {
650
+ type: 'boolean',
651
+ default: true,
652
+ description: 'When true (default), auto-post a session-close broadcast to the family board. Set false to skip.'
653
+ },
654
+ board_type: {
655
+ type: 'string',
656
+ enum: ['stuck', 'turn', 'collide', 'gate'],
657
+ default: 'turn',
658
+ description: 'Board post type for the auto broadcast (default "turn")'
659
+ },
660
+ board_topic: {
661
+ type: 'string',
662
+ default: 'session-close',
663
+ description: 'Board topic tag for the auto broadcast (default "session-close")'
600
664
  }
601
665
  },
602
666
  required: ['source', 'summary'],
@@ -775,6 +839,80 @@ function buildTools() {
775
839
  },
776
840
  additionalProperties: false
777
841
  }
842
+ },
843
+ // ── MARS-318: Board v0 ─────────────────────────────────────
844
+ {
845
+ name: 'board_read',
846
+ description: 'Read unread board posts (the CoCo family wall). Returns posts not yet acknowledged by the calling body. Called automatically by session_boot, but can also be called manually.',
847
+ inputSchema: {
848
+ type: 'object',
849
+ properties: {
850
+ body: { type: 'string', description: 'The body name reading the board (e.g. "三哥", "四哥", "大家姐", "leo")' },
851
+ limit: { type: 'number', minimum: 1, maximum: 50, default: 10, description: 'Maximum posts to return' },
852
+ topic: { type: 'string', description: 'Optional topic filter' },
853
+ include_acked: { type: 'boolean', default: false, description: 'Include already-acknowledged posts' }
854
+ },
855
+ required: ['body'],
856
+ additionalProperties: false
857
+ }
858
+ },
859
+ {
860
+ name: 'board_post',
861
+ description: 'Post a message to the CoCo family board (wall). to="all" broadcasts to the wall; to="<body>" is a private note.',
862
+ inputSchema: {
863
+ type: 'object',
864
+ properties: {
865
+ from_source: { type: 'string', description: 'Source platform (warp / cursor / perplexity / grok / hermes / draft)' },
866
+ from_body: { type: 'string', description: 'Posting body name (e.g. "三哥", "leo")' },
867
+ to: { type: 'string', default: 'all', description: '"all" for wall broadcast; specific body name for private note' },
868
+ topic: { type: 'string', description: 'Topic tag (e.g. "board-v0", "egress")' },
869
+ type: { type: 'string', enum: ['stuck', 'turn', 'collide', 'gate'], default: 'turn', description: 'Post type' },
870
+ text: { type: 'string', description: 'Post content (max 2000 chars)' },
871
+ expires_at: { type: 'string', description: 'Optional expiry in ISO 8601. null = permanent.' }
872
+ },
873
+ required: ['from_source', 'from_body', 'text'],
874
+ additionalProperties: false
875
+ }
876
+ },
877
+ {
878
+ name: 'board_ack',
879
+ description: 'Acknowledge (mark as read) one or more board posts for a specific body.',
880
+ inputSchema: {
881
+ type: 'object',
882
+ properties: {
883
+ body: { type: 'string', description: 'The body acknowledging (e.g. "三哥")' },
884
+ ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50, description: 'Board post UUIDs to acknowledge' }
885
+ },
886
+ required: ['body', 'ids'],
887
+ additionalProperties: false
888
+ }
889
+ },
890
+ // MARS-327: board_get_full — retrieve full text of a single board post
891
+ {
892
+ name: 'board_get_full',
893
+ description: 'Get the full untruncated text of a single board post. Use when board_read or session_boot shows truncated=true and you need the complete content.',
894
+ inputSchema: {
895
+ type: 'object',
896
+ properties: {
897
+ id: { type: 'string', description: 'Board post UUID' }
898
+ },
899
+ required: ['id'],
900
+ additionalProperties: false
901
+ }
902
+ },
903
+ // board_resolve — mark posts as globally resolved
904
+ {
905
+ name: 'board_resolve',
906
+ description: 'Resolve one or more board posts globally. Resolved posts are hidden from board_read and session_boot for ALL bodies (unlike ack which is per-body). Use to close out stuck/gate posts that have been addressed.',
907
+ inputSchema: {
908
+ type: 'object',
909
+ properties: {
910
+ body: { type: 'string', description: 'The body resolving (e.g. "三哥")' },
911
+ ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50, description: 'Board post UUIDs to resolve' }
912
+ },
913
+ required: ['body', 'ids'],
914
+ additionalProperties: false
915
+ }
778
916
  }
779
917
  ];
780
918
  }
@@ -1055,6 +1193,8 @@ function listAvailableToolsForAuthMode(authMode) {
1055
1193
  function buildSupabaseCapabilityPayload() {
1056
1194
  return {
1057
1195
  mode: SUPABASE_AUTH_MODE,
1196
+ tool_surface: 'coco',
1197
+ prd_tools_enabled: false,
1058
1198
  anon_allowed_tools: listAvailableToolNamesForAuthMode('anon'),
1059
1199
  effective_tools: listAvailableToolNamesForAuthMode(SUPABASE_AUTH_MODE)
1060
1200
  };
@@ -1382,6 +1522,46 @@ function normalizeOptionalText(value, maxLength = 200) {
1382
1522
  if (!text) return '';
1383
1523
  return text.slice(0, Math.max(1, maxLength));
1384
1524
  }
1525
+
1526
+ function truncateChunkContent(text, maxChars) {
1527
+ const raw = String(text ?? '');
1528
+ if (raw.length <= maxChars) {
1529
+ return { content: raw, preview_truncated: false, content_chars: raw.length };
1530
+ }
1531
+ return {
1532
+ content: raw.slice(0, maxChars),
1533
+ preview_truncated: true,
1534
+ content_chars: raw.length
1535
+ };
1536
+ }
1537
+
1538
+ function applyRecallPreviewToItem(item) {
1539
+ const fullContent = String(item?.content ?? '');
1540
+ const preview = truncateChunkContent(fullContent, RECALL_PREVIEW_MAX_CHARS);
1541
+ return {
1542
+ ...item,
1543
+ content: preview.content,
1544
+ preview_truncated: preview.preview_truncated,
1545
+ content_chars: preview.content_chars
1546
+ };
1547
+ }
1548
+
1549
+ function buildChunkToolFields(chunk) {
1550
+ return {
1551
+ id: chunk.id,
1552
+ source_file: chunk.source_file || null,
1553
+ section: chunk.section || null,
1554
+ body: chunk.body || null,
1555
+ visibility: chunk.visibility || null,
1556
+ tags: chunk.tags ?? [],
1557
+ type: chunk.type || null,
1558
+ date: chunk.date || null,
1559
+ origin: chunk.origin || null,
1560
+ agent_body: chunk.agent_body || null,
1561
+ environment: chunk.environment || null,
1562
+ created_at: chunk.created_at || null
1563
+ };
1564
+ }
1385
1565
  function normalizeOptionalSourceTool(value) {
1386
1566
  const normalized = String(value || '').trim();
1387
1567
  if (!normalized) return '';
@@ -1518,10 +1698,16 @@ function estimateToolUsageTokens(toolName, args = {}) {
1518
1698
  appendTokenCharCount(safeArgs.query, state);
1519
1699
  appendTokenCharCount(safeArgs.type, state);
1520
1700
  break;
1701
+ case 'get_summary':
1702
+ case 'get_full':
1703
+ appendTokenCharCount(safeArgs.id, state);
1704
+ break;
1521
1705
  case 'session_close':
1522
1706
  appendTokenCharCount(safeArgs.summary, state);
1523
1707
  appendTokenCharCount(safeArgs.topics, state);
1524
1708
  appendTokenCharCount(safeArgs.mood, state);
1709
+ appendTokenCharCount(safeArgs.body_name, state);
1710
+ appendTokenCharCount(safeArgs.board_topic, state);
1525
1711
  break;
1526
1712
  case 'session_boot':
1527
1713
  appendTokenCharCount(safeArgs.topic, state);
@@ -2089,6 +2275,64 @@ async function upsertMemoryBySession({
2089
2275
  return fetched || null;
2090
2276
  }
2091
2277
 
2278
+ // ─── 便條 (Body-to-Body handoff notes) ────────────────────────────────────────
2279
+ // Stored as memories rows with recipient_body/note/read_at columns.
2280
+ // session_close(to, note) writes; session_boot(body) reads + marks read.
2281
+ // If the note columns are not yet deployed, these degrade gracefully (caller wraps in try/catch).
2282
+
2283
+ const NOTE_SELECT_COLUMNS =
2284
+ 'id,body,source,session_id,tags,agent_body,environment,created_at,expires_at,recipient_body,note,read_at';
2285
+
2286
+ async function insertNoteMemory({ source, to, note, body, tags, expires_at }) {
2287
+ const resolvedAgentBody = resolveWriteAgentBody({ source });
2288
+ const resolvedEnvironment = resolveWriteEnvironment({});
2289
+ const createdId = crypto.randomUUID();
2290
+ const payload = {
2291
+ id: createdId,
2292
+ body,
2293
+ source,
2294
+ session_id: `note:to:${to}:${createdId.slice(0, 8)}`,
2295
+ tags: normalizeMemoryTagsWithContext(tags, resolvedAgentBody, resolvedEnvironment),
2296
+ agent_body: resolvedAgentBody,
2297
+ environment: resolvedEnvironment,
2298
+ recipient_body: to,
2299
+ note,
2300
+ read_at: null
2301
+ };
2302
+ if (expires_at) payload.expires_at = expires_at;
2303
+ const inserted = await supabaseRequest(`/rest/v1/memories?select=${NOTE_SELECT_COLUMNS}`, {
2304
+ method: 'POST',
2305
+ profile: DB_PROFILE,
2306
+ prefer: 'return=representation',
2307
+ body: [payload]
2308
+ });
2309
+ return Array.isArray(inserted) && inserted.length > 0 ? inserted[0] : null;
2310
+ }
2311
+
2312
+ async function listUnreadNotes(bodyName, limit = 3) {
2313
+ const query = new URLSearchParams();
2314
+ query.set('select', NOTE_SELECT_COLUMNS);
2315
+ query.set('recipient_body', `eq.${bodyName}`);
2316
+ query.set('read_at', 'is.null');
2317
+ query.set('order', 'created_at.desc');
2318
+ query.set('limit', String(Math.min(limit, 10)));
2319
+ const rows = await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
2320
+ profile: DB_PROFILE
2321
+ });
2322
+ return Array.isArray(rows) ? rows : [];
2323
+ }
2324
+
2325
+ async function markNoteRead(id) {
2326
+ const query = new URLSearchParams();
2327
+ query.set('id', `eq.${id}`);
2328
+ await supabaseRequest(`/rest/v1/memories?${query.toString()}`, {
2329
+ method: 'PATCH',
2330
+ profile: DB_PROFILE,
2331
+ prefer: 'return=minimal',
2332
+ body: { read_at: new Date().toISOString() }
2333
+ });
2334
+ }
2335
+
2092
2336
  async function ensureDailyCloseRecovery(source, nowTs) {
2093
2337
  const yesterdayDate = new Date(toUtcStartOfDay(nowTs).getTime() - 86400000);
2094
2338
  const yesterdayDateKey = formatDateOnly(yesterdayDate);
@@ -2180,6 +2424,7 @@ async function runDailyBoot(args = {}) {
2180
2424
  const source = normalizeDailySource(args.source);
2181
2425
  const topic = normalizeOptionalText(args.topic, 200);
2182
2426
  const mood = normalizeOptionalText(args.mood, 80);
2427
+ const bodyName = normalizeOptionalText(args.body, 60) || DB_PROFILE;
2183
2428
  const queries = buildDailyBootQueries(args);
2184
2429
  const inferredSourceAgentBody = inferAgentBodyFromSource(source);
2185
2430
  const recallScopeArgs = {
@@ -2273,12 +2518,85 @@ async function runDailyBoot(args = {}) {
2273
2518
  }
2274
2519
  : await ensureDailyCloseRecovery(source, nowTs);
2275
2520
 
2521
+ // 便條: surface unread handoff notes addressed to this body, then mark read
2522
+ let notes = [];
2523
+ if (bodyName) {
2524
+ try {
2525
+ const unread = await listUnreadNotes(bodyName);
2526
+ for (const n of unread) {
2527
+ notes.push({
2528
+ id: n.id,
2529
+ from: normalizeOptionalText(n.source, 60) || 'unknown',
2530
+ to: bodyName,
2531
+ note: normalizeOptionalText(n.note, 1000) || '',
2532
+ created_at: n.created_at
2533
+ });
2534
+ await markNoteRead(n.id);
2535
+ }
2536
+ } catch (error) {
2537
+ // note columns may not be deployed yet — degrade gracefully, boot itself still succeeds
2538
+ notes = [];
2539
+ }
2540
+ }
2541
+
2542
+ // MARS-318: Board v0 — auto-fetch unread wall posts for this body
2543
+ let boardUnread = [];
2544
+ const boardBody = bodyName || inferAgentBodyFromSource(source) || DB_PROFILE;
2545
+ try {
2546
+ const bQuery = new URLSearchParams();
2547
+ bQuery.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,created_at');
2548
+ bQuery.set('order', 'created_at.desc');
2549
+ bQuery.set('limit', '5');
2550
+ bQuery.set('archived_at', 'is.null');
2551
+ bQuery.set('resolved_at', 'is.null');
2552
+ bQuery.set('or', `(to.eq.all,to.eq.${boardBody})`);
2553
+ const bResult = await supabaseRequest(`/rest/v1/board_posts?${bQuery.toString()}`, { profile: DB_PROFILE });
2554
+ const bPosts = Array.isArray(bResult) ? bResult : [];
2555
+ const BOOT_BOARD_PREVIEW_MAX = 160;
2556
+ // MARS-328: priority types surface first
2557
+ const BOARD_PRIORITY_TYPES = new Set(['gate', 'stuck']);
2558
+ boardUnread = bPosts
2559
+ .filter(p => {
2560
+ const ab = p.acked_by && typeof p.acked_by === 'object' ? p.acked_by : {};
2561
+ return !ab[boardBody];
2562
+ })
2563
+ .map(p => {
2564
+ const fullText = p.text || '';
2565
+ const preview = fullText.slice(0, BOOT_BOARD_PREVIEW_MAX);
2566
+ return {
2567
+ id: p.id,
2568
+ from: `${p.from_body}@${p.from_source}`,
2569
+ to: p.to,
2570
+ topic: p.topic || null,
2571
+ type: p.type,
2572
+ text: preview,
2573
+ truncated: fullText.length > BOOT_BOARD_PREVIEW_MAX,
2574
+ full_length: fullText.length,
2575
+ preview_length: preview.length,
2576
+ created_at: p.created_at
2577
+ };
2578
+ })
2579
+ .sort((a, b) => {
2580
+ const aPri = BOARD_PRIORITY_TYPES.has(a.type) ? 0 : 1;
2581
+ const bPri = BOARD_PRIORITY_TYPES.has(b.type) ? 0 : 1;
2582
+ if (aPri !== bPri) return aPri - bPri;
2583
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
2584
+ });
2585
+ } catch {
2586
+ // board_posts table may not exist yet — degrade gracefully
2587
+ boardUnread = [];
2588
+ }
2589
+
2276
2590
  return {
2277
2591
  ok: true,
2278
2592
  profile: DB_PROFILE,
2279
2593
  mode: existingHeartbeat ? 'welcome_back' : 'new_day',
2280
2594
  source,
2281
2595
  date: dateKey,
2596
+ notes,
2597
+ board_unread: boardUnread,
2598
+ // MARS-328: remind body to post stuck/gate when blocked mid-session
2599
+ board_reminder: '中途卡住請用 board_post type=stuck 上牆,唔好等到 session_close。',
2282
2600
  heartbeat_id: savedHeartbeat?.id || null,
2283
2601
  agent_name: queries.agent_name,
2284
2602
  last_topic: previousTopic || null,
@@ -2313,6 +2631,8 @@ async function runDailyClose(args = {}) {
2313
2631
  }
2314
2632
  const topics = normalizeDailyTopics(args.topics);
2315
2633
  const mood = normalizeOptionalText(args.mood, 80);
2634
+ const to = normalizeOptionalText(args.to, 60);
2635
+ const note = to ? normalizeOptionalText(args.note, 1000) : null;
2316
2636
  const nowTs = new Date();
2317
2637
  const dateKey = dailyDateKey(nowTs);
2318
2638
  const toolUsageDailySummary = await fetchToolUsageDailySummary(dateKey);
@@ -2344,6 +2664,23 @@ async function runDailyClose(args = {}) {
2344
2664
  expires_at: expiresAt,
2345
2665
  existing: existingClose
2346
2666
  });
2667
+
2668
+ // Auto-promote soon-expiring short-term memories (replaces Hermes dream schedule).
2669
+ // Failure must never break session_close — summary is already stored.
2670
+ let promotedCount = 0;
2671
+ try {
2672
+ const promoteResult = await runBatchPromote({
2673
+ alert_window_hours: 48,
2674
+ max_promote: 5
2675
+ });
2676
+ promotedCount = Math.max(0, Number(promoteResult?.promoted_count) || 0);
2677
+ } catch (error) {
2678
+ console.warn(
2679
+ `[session_close] auto batch_promote failed: ${String(error?.message || error)}`
2680
+ );
2681
+ promotedCount = 0;
2682
+ }
2683
+
2347
2684
  const expirySnapshot = await runHealthExpiryCheck({
2348
2685
  alert_window_hours: 48
2349
2686
  });
@@ -2356,11 +2693,87 @@ async function runDailyClose(args = {}) {
2356
2693
  Number(expirySnapshot?.expiry_alert?.recommended_for_promotion_count) || 0
2357
2694
  );
2358
2695
  const expiryAction =
2359
- recommendPromoteCount > 0
2360
- ? '建議叫 Hermes 或三哥 promote'
2361
- : soonExpiringCount > 0
2362
- ? '有短記憶即將到期,建議先做 health_check 檢視'
2363
- : null;
2696
+ promotedCount > 0
2697
+ ? `auto-promoted ${promotedCount} expiring memor${promotedCount === 1 ? 'y' : 'ies'}`
2698
+ : recommendPromoteCount > 0
2699
+ ? 'expiring memories remain — call batch_promote or wait for next session_close'
2700
+ : soonExpiringCount > 0
2701
+ ? 'short-term memories approaching expiry — review with health_check if needed'
2702
+ : null;
2703
+
2704
+ // 便條: optional handoff note addressed to another body
2705
+ let noteResult = null;
2706
+ if (to) {
2707
+ try {
2708
+ const noteTags = buildLifecycleMemoryTags(['note', `to:${to}`], source, dateKey);
2709
+ const notePayload = {
2710
+ type: 'note',
2711
+ profile: DB_PROFILE,
2712
+ source,
2713
+ to,
2714
+ note,
2715
+ date: dateKey,
2716
+ created_at: nowTs.toISOString()
2717
+ };
2718
+ const savedNote = await insertNoteMemory({
2719
+ source,
2720
+ to,
2721
+ note: note || '',
2722
+ body: JSON.stringify(notePayload),
2723
+ tags: noteTags,
2724
+ expires_at: expiresAt
2725
+ });
2726
+ noteResult = { to, note_id: savedNote?.id || null, delivered: Boolean(savedNote) };
2727
+ } catch (error) {
2728
+ // note columns may not be deployed yet — degrade gracefully, close itself still succeeds
2729
+ noteResult = { to, delivered: false, error: String(error?.message || error) };
2730
+ }
2731
+ }
2732
+
2733
+ // Auto-post a session-close broadcast to the CoCo family board.
2734
+ // Mirrors board_post insert; failures never break session_close (summary already stored).
2735
+ let boardPostResult = null;
2736
+ const autoBoardPost = args.auto_board_post !== false;
2737
+ if (autoBoardPost) {
2738
+ try {
2739
+ const boardFromSource = source;
2740
+ const boardFromBody = normalizeOptionalText(args.body_name, 60) || DB_PROFILE;
2741
+ const boardTo = 'all';
2742
+ const boardType = ['stuck', 'turn', 'collide', 'gate'].includes(args.board_type)
2743
+ ? args.board_type
2744
+ : 'turn';
2745
+ const boardTopic = normalizeOptionalText(args.board_topic, 60) || 'session-close';
2746
+ const boardText = String(summary || '').slice(0, 2000);
2747
+ if (boardText) {
2748
+ const boardPayload = {
2749
+ from_source: boardFromSource,
2750
+ from_body: boardFromBody,
2751
+ to: boardTo,
2752
+ topic: boardTopic,
2753
+ type: boardType,
2754
+ text: boardText
2755
+ };
2756
+ const boardInserted = await supabaseRequest(
2757
+ '/rest/v1/board_posts?select=id,from_source,from_body,to,topic,type,text,created_at',
2758
+ {
2759
+ method: 'POST',
2760
+ profile: DB_PROFILE,
2761
+ prefer: 'return=representation',
2762
+ body: [boardPayload]
2763
+ }
2764
+ );
2765
+ boardPostResult = { posted: true, id: boardInserted?.[0]?.id || null };
2766
+ } else {
2767
+ boardPostResult = { posted: false, reason: 'empty summary' };
2768
+ }
2769
+ } catch (error) {
2770
+ // board_posts table may not exist yet, or insert failed — degrade gracefully.
2771
+ console.warn(`[session_close] auto board_post failed: ${String(error?.message || error)}`);
2772
+ boardPostResult = { posted: false, error: String(error?.message || error) };
2773
+ }
2774
+ } else {
2775
+ boardPostResult = { posted: false, skipped: true };
2776
+ }
2364
2777
 
2365
2778
  return {
2366
2779
  ok: true,
@@ -2372,6 +2785,9 @@ async function runDailyClose(args = {}) {
2372
2785
  topics,
2373
2786
  mood: mood || null,
2374
2787
  tool_usage_daily_summary: toolUsageDailySummary,
2788
+ note: noteResult,
2789
+ board_post: boardPostResult,
2790
+ promoted_count: promotedCount,
2375
2791
  expiry_alert: {
2376
2792
  soon_expiring_count: soonExpiringCount,
2377
2793
  recommend_promote_count: recommendPromoteCount,
@@ -2440,6 +2856,15 @@ function evaluatePromotionCandidate(memory, nowTs, longTermTagSet) {
2440
2856
  reason: '已 promoted'
2441
2857
  };
2442
2858
  }
2859
+ // Heartbeat / session_close lifecycle memories should never be promoted —
2860
+ // they contain query strings that pollute recall results with high similarity.
2861
+ if (isLifecycleMemoryRow(memory)) {
2862
+ return {
2863
+ recommend: 'N',
2864
+ score: 0,
2865
+ reason: 'lifecycle memory (heartbeat/session_close) 唔應升級'
2866
+ };
2867
+ }
2443
2868
  const reasons = [];
2444
2869
  let score = 0;
2445
2870
 
@@ -3439,6 +3864,45 @@ function buildPromoteTimeline(chunk, sourceMemory) {
3439
3864
  source_to_promoted_seconds: sourceToPromotedSeconds
3440
3865
  };
3441
3866
  }
3867
+
3868
+ async function runGetSummary(args = {}) {
3869
+ const chunkId = normalizeOptionalUuid(args.id, 'id');
3870
+ if (!chunkId) {
3871
+ throw new Error('id is required');
3872
+ }
3873
+ const chunk = await fetchChunkById(chunkId);
3874
+ if (!chunk) {
3875
+ throw new Error(`memory chunk not found: ${chunkId}`);
3876
+ }
3877
+ const preview = truncateChunkContent(chunk.content, CHUNK_SUMMARY_MAX_CHARS);
3878
+ return {
3879
+ ok: true,
3880
+ ...buildChunkToolFields(chunk),
3881
+ content: preview.content,
3882
+ preview_truncated: preview.preview_truncated,
3883
+ content_chars: preview.content_chars,
3884
+ drill_down: 'Use get_full for complete chunk text.'
3885
+ };
3886
+ }
3887
+
3888
+ async function runGetFull(args = {}) {
3889
+ const chunkId = normalizeOptionalUuid(args.id, 'id');
3890
+ if (!chunkId) {
3891
+ throw new Error('id is required');
3892
+ }
3893
+ const chunk = await fetchChunkById(chunkId);
3894
+ if (!chunk) {
3895
+ throw new Error(`memory chunk not found: ${chunkId}`);
3896
+ }
3897
+ const fullContent = String(chunk.content ?? '');
3898
+ return {
3899
+ ok: true,
3900
+ ...buildChunkToolFields(chunk),
3901
+ content: fullContent,
3902
+ content_chars: fullContent.length
3903
+ };
3904
+ }
3905
+
3442
3906
  async function runExplainMemory(args = {}) {
3443
3907
  const chunkId = normalizeOptionalUuid(args.id, 'id');
3444
3908
  if (!chunkId) {
@@ -3765,9 +4229,15 @@ async function callTool(name, args = {}) {
3765
4229
  }
3766
4230
  }
3767
4231
  : {}),
3768
- items: explained.items
4232
+ items: explained.items.map(applyRecallPreviewToItem)
3769
4233
  };
3770
4234
  }
4235
+ if (toolName === 'get_summary') {
4236
+ return await runGetSummary(args);
4237
+ }
4238
+ if (toolName === 'get_full') {
4239
+ return await runGetFull(args);
4240
+ }
3771
4241
  if (toolName === 'explain_memory') {
3772
4242
  return await runExplainMemory(args);
3773
4243
  }
@@ -3993,6 +4463,204 @@ async function callTool(name, args = {}) {
3993
4463
  };
3994
4464
  }
3995
4465
 
4466
+ // ── MARS-318: Board v0 handlers ──────────────────────────
4467
+ if (toolName === 'board_read') {
4468
+ const body = String(args.body || '').trim();
4469
+ if (!body) throw new Error('body is required');
4470
+ const limitRaw = Number(args.limit ?? 10);
4471
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(50, Math.trunc(limitRaw))) : 10;
4472
+ const includeAcked = Boolean(args.include_acked);
4473
+ const topic = args.topic ? String(args.topic).trim() : '';
4474
+
4475
+ const query = new URLSearchParams();
4476
+ query.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,expires_at,archived_at,created_at');
4477
+ query.set('order', 'created_at.desc');
4478
+ query.set('limit', String(limit));
4479
+ query.set('archived_at', 'is.null');
4480
+ query.set('resolved_at', 'is.null');
4481
+ // Wall posts (to=all) + posts addressed to this body
4482
+ query.set('or', `(to.eq.all,to.eq.${body})`);
4483
+ if (topic) query.set('topic', `eq.${topic}`);
4484
+ // Exclude expired
4485
+ query.set('or', `(expires_at.is.null,expires_at.gt.${new Date().toISOString()})`);
4486
+
4487
+ const result = await supabaseRequest(`/rest/v1/board_posts?${query.toString()}`, { profile: DB_PROFILE });
4488
+ const posts = Array.isArray(result) ? result : [];
4489
+
4490
+ // Filter out already-acked posts unless include_acked
4491
+ const filtered = includeAcked
4492
+ ? posts
4493
+ : posts.filter(p => {
4494
+ const ackedBy = p.acked_by && typeof p.acked_by === 'object' ? p.acked_by : {};
4495
+ return !ackedBy[body];
4496
+ });
4497
+
4498
+ const BOARD_PREVIEW_MAX = 160;
4499
+ // MARS-328: gate/stuck surface first
4500
+ const BOARD_READ_PRIORITY_TYPES = new Set(['gate', 'stuck']);
4501
+ const mapped = filtered.map(p => {
4502
+ const fullText = p.text || '';
4503
+ const preview = fullText.slice(0, BOARD_PREVIEW_MAX);
4504
+ return {
4505
+ id: p.id,
4506
+ from: `${p.from_body}@${p.from_source}`,
4507
+ to: p.to,
4508
+ topic: p.topic || null,
4509
+ type: p.type,
4510
+ text: preview,
4511
+ truncated: fullText.length > BOARD_PREVIEW_MAX,
4512
+ full_length: fullText.length,
4513
+ preview_length: preview.length,
4514
+ created_at: p.created_at,
4515
+ acked: !!(p.acked_by && p.acked_by[body])
4516
+ };
4517
+ }).sort((a, b) => {
4518
+ const aPri = BOARD_READ_PRIORITY_TYPES.has(a.type) ? 0 : 1;
4519
+ const bPri = BOARD_READ_PRIORITY_TYPES.has(b.type) ? 0 : 1;
4520
+ if (aPri !== bPri) return aPri - bPri;
4521
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
4522
+ });
4523
+ return {
4524
+ ok: true,
4525
+ count: mapped.length,
4526
+ body,
4527
+ posts: mapped
4528
+ };
4529
+ }
4530
+
4531
+ if (toolName === 'board_post') {
4532
+ const fromSource = String(args.from_source || '').trim();
4533
+ const fromBody = String(args.from_body || '').trim();
4534
+ const to = String(args.to || 'all').trim();
4535
+ const topic = args.topic ? String(args.topic).trim() : null;
4536
+ const type = args.type || 'turn';
4537
+ const text = String(args.text || '').trim();
4538
+ const expiresAt = args.expires_at ? String(args.expires_at).trim() : null;
4539
+
4540
+ if (!fromSource) throw new Error('from_source is required');
4541
+ if (!fromBody) throw new Error('from_body is required');
4542
+ if (!text) throw new Error('text is required');
4543
+ if (text.length > 2000) throw new Error('text too long (max 2000 chars)');
4544
+
4545
+ const payload = {
4546
+ from_source: fromSource,
4547
+ from_body: fromBody,
4548
+ to,
4549
+ topic,
4550
+ type,
4551
+ text
4552
+ };
4553
+ if (expiresAt) payload.expires_at = expiresAt;
4554
+
4555
+ const result = await supabaseRequest(
4556
+ '/rest/v1/board_posts?select=id,from_source,from_body,to,topic,type,text,created_at',
4557
+ {
4558
+ method: 'POST',
4559
+ profile: DB_PROFILE,
4560
+ prefer: 'return=representation',
4561
+ body: [payload]
4562
+ }
4563
+ );
4564
+ const inserted = result?.[0] ?? null;
4565
+ return {
4566
+ ok: true,
4567
+ inserted,
4568
+ text_length: text.length
4569
+ };
4570
+ }
4571
+
4572
+ if (toolName === 'board_ack') {
4573
+ const body = String(args.body || '').trim();
4574
+ const ids = Array.isArray(args.ids) ? args.ids.map(id => String(id).trim()).filter(Boolean) : [];
4575
+ if (!body) throw new Error('body is required');
4576
+ if (!ids.length) throw new Error('ids is required (at least 1)');
4577
+
4578
+ const now = new Date().toISOString();
4579
+ let acked = 0;
4580
+ const notFoundIds = [];
4581
+ for (const id of ids) {
4582
+ // Read current acked_by, merge, write back
4583
+ const rows = await supabaseRequest(
4584
+ `/rest/v1/board_posts?id=eq.${id}&select=id,acked_by`,
4585
+ { profile: DB_PROFILE }
4586
+ );
4587
+ const row = rows?.[0];
4588
+ if (!row) { notFoundIds.push(id); continue; }
4589
+ const ackedBy = row.acked_by && typeof row.acked_by === 'object' ? { ...row.acked_by } : {};
4590
+ ackedBy[body] = now;
4591
+ await supabaseRequest(
4592
+ `/rest/v1/board_posts?id=eq.${id}`,
4593
+ {
4594
+ method: 'PATCH',
4595
+ profile: DB_PROFILE,
4596
+ prefer: 'return=minimal',
4597
+ body: { acked_by: ackedBy }
4598
+ }
4599
+ );
4600
+ acked++;
4601
+ }
4602
+ return { ok: true, acked, body, not_found_ids: notFoundIds };
4603
+ }
4604
+
4605
+ // board_resolve — mark posts as globally resolved (hidden for all bodies)
4606
+ if (toolName === 'board_resolve') {
4607
+ const body = String(args.body || '').trim();
4608
+ const ids = Array.isArray(args.ids) ? args.ids.map(id => String(id).trim()).filter(Boolean) : [];
4609
+ if (!body) throw new Error('body is required');
4610
+ if (!ids.length) throw new Error('ids is required (at least 1)');
4611
+
4612
+ const now = new Date().toISOString();
4613
+ let resolved = 0;
4614
+ const notFoundIds = [];
4615
+ for (const id of ids) {
4616
+ const rows = await supabaseRequest(
4617
+ `/rest/v1/board_posts?id=eq.${id}&select=id,resolved_at`,
4618
+ { profile: DB_PROFILE }
4619
+ );
4620
+ const row = rows?.[0];
4621
+ if (!row) { notFoundIds.push(id); continue; }
4622
+ if (row.resolved_at) continue; // already resolved
4623
+ await supabaseRequest(
4624
+ `/rest/v1/board_posts?id=eq.${id}`,
4625
+ {
4626
+ method: 'PATCH',
4627
+ profile: DB_PROFILE,
4628
+ prefer: 'return=minimal',
4629
+ body: { resolved_at: now }
4630
+ }
4631
+ );
4632
+ resolved++;
4633
+ }
4634
+ return { ok: true, resolved, body, not_found_ids: notFoundIds };
4635
+ }
4636
+
4637
+ // MARS-327: board_get_full — single post full text retrieval
4638
+ if (toolName === 'board_get_full') {
4639
+ const id = String(args.id || '').trim();
4640
+ if (!id) throw new Error('id is required');
4641
+
4642
+ const query = new URLSearchParams();
4643
+ query.set('id', `eq.${id}`);
4644
+ query.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,expires_at,archived_at,created_at');
4645
+ const rows = await supabaseRequest(`/rest/v1/board_posts?${query.toString()}`, { profile: DB_PROFILE });
4646
+ const post = rows?.[0];
4647
+ if (!post) throw new Error(`board post not found: ${id}`);
4648
+
4649
+ return {
4650
+ ok: true,
4651
+ post: {
4652
+ id: post.id,
4653
+ from: `${post.from_body}@${post.from_source}`,
4654
+ to: post.to,
4655
+ topic: post.topic || null,
4656
+ type: post.type,
4657
+ text: post.text || '',
4658
+ full_length: (post.text || '').length,
4659
+ created_at: post.created_at
4660
+ }
4661
+ };
4662
+ }
4663
+
3996
4664
  throw new Error(`Unknown tool: ${name}`);
3997
4665
  } finally {
3998
4666
  await writeToolUsageTelemetry({
@@ -4054,15 +4722,14 @@ function isPublicHost(host) {
4054
4722
  }
4055
4723
 
4056
4724
  function shouldRequireBearer(req) {
4057
- if (!REQUIRE_BEARER) return false;
4058
- const host = extractRequestHost(req);
4059
- if (BYPASS_BEARER_FOR_PRIVATE && isPrivateHost(host)) {
4060
- return false;
4061
- }
4062
- if (isPublicHost(host)) {
4063
- return true;
4064
- }
4065
- return true;
4725
+ // MARS-422: never trust client-supplied X-Forwarded-Host on edge traffic.
4726
+ // See lib/auth-guard.mjs and scripts/tests/test_auth_guard.mjs.
4727
+ return guardShouldRequireBearer({
4728
+ headers: req.headers,
4729
+ remoteAddress: req.socket?.remoteAddress,
4730
+ requireBearer: REQUIRE_BEARER,
4731
+ bypassForPrivate: BYPASS_BEARER_FOR_PRIVATE
4732
+ });
4066
4733
  }
4067
4734
 
4068
4735
  function oauthProtectedResourceMetadata(baseUrl) {
@@ -4565,6 +5232,25 @@ const server = http.createServer(async (req, res) => {
4565
5232
  return;
4566
5233
  }
4567
5234
 
5235
+ // Streamable HTTP: this server is POST-only (no SSE streaming of
5236
+ // server-initiated notifications). Per MCP spec, respond to GET /mcp with
5237
+ // 405 Method Not Allowed + an `allow` header so compliant clients (Cursor,
5238
+ // Claude Desktop, etc.) fall back to POST-only request/response instead
5239
+ // of treating the missing stream as a fatal transport error (404 would
5240
+ // cause Cursor's V2 FSM to tombstone the transport after 5 retries).
5241
+ if (
5242
+ req.method === 'GET' &&
5243
+ ['/mcp', '/mcp/', '/'].includes(url.pathname)
5244
+ ) {
5245
+ res.writeHead(405, {
5246
+ 'access-control-allow-origin': '*',
5247
+ allow: 'POST',
5248
+ 'cache-control': 'no-store'
5249
+ });
5250
+ res.end();
5251
+ return;
5252
+ }
5253
+
4568
5254
  if (req.method !== 'POST' || !['/mcp', '/mcp/', '/'].includes(url.pathname)) {
4569
5255
  json(res, 404, { error: 'not_found' });
4570
5256
  return;