@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.
- package/deploy/phase2/build_release_artifact.sh +5 -5
- package/deploy/phase2/pre_deploy_schema_gate.sh +1 -1
- package/deploy/phase3/smoke_gate.sh +11 -5
- package/deploy/systemd/memory-mcp-gateway@.service +6 -6
- package/package.json +2 -2
- package/scripts/tests/test_auth_guard.mjs +74 -0
- package/scripts/tests/test_dream_runner_modes.py +2 -2
- package/server.mjs +706 -20
|
@@ -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='
|
|
35
|
-
--exclude='
|
|
34
|
+
--exclude='marsnme-supabase/deploy/artifacts' \
|
|
35
|
+
--exclude='marsnme-supabase/deploy/phase0/ct101-baseline-*' \
|
|
36
36
|
README.md \
|
|
37
|
-
|
|
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}/
|
|
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": "
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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/
|
|
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/
|
|
13
|
-
EnvironmentFile=-/opt/mars-memory-mcp/current-%i/
|
|
14
|
-
EnvironmentFile=-/opt/mars-memory-mcp/current/
|
|
15
|
-
EnvironmentFile=-/opt/mars-memory-mcp/current/
|
|
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/
|
|
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.
|
|
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 / "
|
|
12
|
-
HERMES_WRAPPER = ROOT / "
|
|
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.
|
|
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
|
-
|
|
2360
|
-
?
|
|
2361
|
-
:
|
|
2362
|
-
? '
|
|
2363
|
-
:
|
|
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
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
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;
|